├── tests ├── __init__.py ├── contrib │ ├── test.flac │ ├── test.m4a │ └── test.mp3 ├── conftest.py ├── test_tag.py └── test_music_fs.py ├── yandex_fuse ├── __init__.py ├── main.py ├── request.py ├── ya_player.py ├── virt_fs.py └── ya_music_fs.py ├── MANIFEST.in ├── .gitattributes ├── contrib └── yamusic-fs.service ├── .gitignore ├── .github └── workflows │ └── python-package.yml ├── README.md ├── pyproject.toml └── LICENSE /tests/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /yandex_fuse/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /MANIFEST.in: -------------------------------------------------------------------------------- 1 | recursive-include contrib *.service 2 | recursive-include yandex_fuse *.py 3 | -------------------------------------------------------------------------------- /tests/contrib/test.flac: -------------------------------------------------------------------------------- 1 | version https://git-lfs.github.com/spec/v1 2 | oid sha256:98f6ef80f9f82fc7ec24f8952438631731a4ae94305a40b0955f251f5c0b34ff 3 | size 1493033 4 | -------------------------------------------------------------------------------- /tests/contrib/test.m4a: -------------------------------------------------------------------------------- 1 | version https://git-lfs.github.com/spec/v1 2 | oid sha256:0889e76cf5b51f2c2fa9f0e3053abdc65b7980cb8a45a75caf943a4ab242be16 3 | size 164738 4 | -------------------------------------------------------------------------------- /tests/contrib/test.mp3: -------------------------------------------------------------------------------- 1 | version https://git-lfs.github.com/spec/v1 2 | oid sha256:13f7cf211cde4883676d3d86229d06fcfa3ff1b2cc94891c8a15447a4fd21565 3 | size 1432510 4 | -------------------------------------------------------------------------------- /.gitattributes: -------------------------------------------------------------------------------- 1 | tests/contrib/test.m4a filter=lfs diff=lfs merge=lfs -text 2 | tests/contrib/test.mp3 filter=lfs diff=lfs merge=lfs -text 3 | tests/contrib/test.flac filter=lfs diff=lfs merge=lfs -text 4 | -------------------------------------------------------------------------------- /contrib/yamusic-fs.service: -------------------------------------------------------------------------------- 1 | [Unit] 2 | Description=Yandex Music FUSE mount 3 | Documentation=https://github.com/vm86/yandex-music-fusefs 4 | Wants=network-online.target 5 | After=network-online.target 6 | 7 | [Service] 8 | Type=notify 9 | ExecStart=%h/.local/bin/yamusic-fs %h/Music/Yandex 10 | ExecStop=/bin/fusermount -u %h/Music/Yandex 11 | RestartSec=10 12 | Restart=always 13 | 14 | [Install] 15 | WantedBy=default.target 16 | -------------------------------------------------------------------------------- /tests/conftest.py: -------------------------------------------------------------------------------- 1 | from io import BytesIO 2 | from pathlib import Path 3 | 4 | import pytest 5 | 6 | 7 | def _load_audio(codec: str) -> BytesIO: 8 | return BytesIO( 9 | initial_bytes=Path(f"tests/contrib/test.{codec}").read_bytes() 10 | ) 11 | 12 | 13 | @pytest.fixture 14 | def m4a_audio() -> BytesIO: 15 | return _load_audio("m4a") 16 | 17 | 18 | @pytest.fixture 19 | def mp3_audio() -> BytesIO: 20 | return _load_audio("mp3") 21 | 22 | 23 | @pytest.fixture 24 | def flac_audio() -> BytesIO: 25 | return _load_audio("flac") 26 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Byte-compiled / optimized 2 | .idea 3 | *.iml 4 | __pycache__/ 5 | *.py[cod] 6 | *$py.class 7 | 8 | 9 | # Distribution / packaging 10 | .Python 11 | build/ 12 | develop-eggs/ 13 | dist/ 14 | downloads/ 15 | eggs/ 16 | .eggs/ 17 | lib/ 18 | lib64/ 19 | parts/ 20 | sdist/ 21 | var/ 22 | wheels/ 23 | pip-wheel-metadata/ 24 | share/python-wheels/ 25 | *.egg-info/ 26 | .installed.cfg 27 | *.egg 28 | MANIFEST 29 | 30 | # PyInstaller 31 | # Usually these files are written by a python script from a template 32 | # before PyInstaller builds the exe, so as to inject date/other infos into it. 33 | *.manifest 34 | *.spec 35 | 36 | # Sphinx documentation 37 | docs/_build/ 38 | 39 | # Environments 40 | .env 41 | .venv 42 | env/ 43 | venv/ 44 | venvLP/ 45 | ENV/ 46 | 47 | # Rope project settings 48 | .ropeproject 49 | 50 | # ruff 51 | .ruff_cache 52 | 53 | yandex_fuse/_version.py 54 | 55 | # coverage 56 | *.coverage 57 | -------------------------------------------------------------------------------- /.github/workflows/python-package.yml: -------------------------------------------------------------------------------- 1 | name: Python package 2 | 3 | on: 4 | push: 5 | branches: [ "main" ] 6 | tags: [ "v*" ] 7 | pull_request: 8 | branches: [ "main" ] 9 | 10 | 11 | jobs: 12 | build: 13 | runs-on: ubuntu-24.04 14 | strategy: 15 | fail-fast: false 16 | matrix: 17 | python-version: ["3.10"] 18 | permissions: 19 | contents: write 20 | steps: 21 | - uses: actions/checkout@v4 22 | with: 23 | lfs: true 24 | - name: Set up Python ${{ matrix.python-version }} 25 | uses: actions/setup-python@v5 26 | with: 27 | python-version: ${{ matrix.python-version }} 28 | - name: Install dependencies 29 | run: | 30 | python -m pip install --upgrade pip 31 | python -m pip install build ruff mypy 32 | sudo apt install fuse3 libfuse3-dev libfuse-dev ffmpeg -y 33 | python -m pip install -e ".[lint]" 34 | - name: Test with pytest 35 | run: | 36 | python -m pip install -e ".[test]" 37 | pytest 38 | - name: Lint code with Ruff 39 | run: | 40 | ruff check --output-format=github 41 | - name: Check code formatting with Ruff 42 | run: | 43 | ruff format --diff 44 | - name: Lint code with mypy 45 | run: | 46 | mypy yandex_fuse 47 | mypy tests 48 | - name: Build package 49 | run: python -m build 50 | - name: Release 51 | uses: softprops/action-gh-release@v2 52 | if: startsWith(github.ref, 'refs/tags/') 53 | with: 54 | generate_release_notes: true 55 | files: | 56 | dist/yandex_fuse-*.tar.gz 57 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Yandex Music FuseFS 2 | 3 | ⚠️ Это неофициальная ФС. 4 | 5 | ## Описание 6 | 7 | Сделано только для себя, чтобы слушать музыку в MPD. 8 | 9 | ### Системные зависимости 10 | 11 | [pyfuse3](https://pyfuse3.readthedocs.io/en/latest/install.html) 12 | 13 | #### Debian\Ubuntu 14 | 15 | ```shell 16 | sudo apt install pkg-config fuse3 libfuse3-dev libfuse-dev -y 17 | ``` 18 | 19 | #### Fedora 20 | 21 | ```shell 22 | sudo dnf install pkg-config fuse3 fuse3-devel python3-devel gcc -y 23 | 24 | ``` 25 | 26 | #### user_allow_other 27 | 28 | ```shell 29 | fusermount3: option allow_other only allowed if 'user_allow_other' is set in /etc/fuse.conf 30 | ``` 31 | 32 | ### Установка 33 | 34 | [Скачать](https://github.com/vm86/yandex-music-fusefs/releases) 35 | 36 | ``` shell 37 | pip install yandex_fuse-*.tar.gz 38 | ``` 39 | 40 | ### Начало работы 41 | 42 | #### Запускаем 43 | 44 | ```shell 45 | yamusic-fs ~/Music/Yandex/ 46 | ``` 47 | 48 | #### Первый запуск 49 | 50 | После запуска, откроется страница в браузере с QR-кодом, 51 | который нужно отсканировать в приложении Я.Ключ. 52 | Если авторизация прошла успешна в логах появится запись "Token saved". 53 | И начнется синхронизация плейлиста "Мне нравится". 54 | После завершения синхронизации в логах будет строка: 55 | "Loaded track in like playlist .." 56 | 57 | #### Отмонтировать 58 | 59 | ```shell 60 | fusermount -u ~/Music/Yandex 61 | ``` 62 | 63 | #### Конфигурация 64 | 65 | ~/.config/yandex-fuse.json 66 | 67 | ```json 68 | { 69 | "token": "", 70 | "quality": "hq", 71 | "blacklist": [], 72 | } 73 | ``` 74 | 75 | token = Токен доступа 76 | 77 | quality = lossless, hq 78 | 79 | blacklist = "Черный список" жанров для "Моя волна" 80 | 81 | #### Логи 82 | 83 | ```shell 84 | journalctl --user -u yamusic-fs.service --no-pager 85 | ``` 86 | 87 | Или 88 | 89 | ```shell 90 | cat ~/.cache/yandex_fuse.log 91 | ``` 92 | -------------------------------------------------------------------------------- /pyproject.toml: -------------------------------------------------------------------------------- 1 | [build-system] 2 | requires = ["setuptools >= 65.0", "setuptools-scm >= 8"] 3 | build-backend = "setuptools.build_meta" 4 | 5 | [project] 6 | name = "yandex-fuse" 7 | dynamic = ["version"] 8 | description = "Yandex Music Fuse FS" 9 | readme = "README.md" 10 | authors = [{ name = "Roman Nebaluev" }] 11 | license = { text = "LGPLv3" } 12 | keywords = ["yandex", "music", "fuse", "filesystem"] 13 | 14 | dependencies = ["yandex-music >=2.2.0", "mutagen >=1.47.0", "pyfuse3 >=3.4.0"] 15 | requires-python = ">=3.10" 16 | 17 | [project.optional-dependencies] 18 | test = ["pytest", "pytest-asyncio", "pytest-mock", "coverage", "pytest-cov"] 19 | lint = ["mypy", "ruff"] 20 | 21 | [project.scripts] 22 | yamusic-fs = "yandex_fuse.main:main" 23 | 24 | [tool.setuptools] 25 | zip-safe = false 26 | include-package-data = true 27 | 28 | [tool.setuptools.packages.find] 29 | include = ["yandex_fuse"] 30 | 31 | [tool.setuptools.package-data] 32 | "yandex_fuse" = ["contrib/*"] 33 | 34 | [tool.setuptools_scm] 35 | version_file = "yandex_fuse/_version.py" 36 | 37 | [tool.pytest.ini_options] 38 | pythonpath = "yandex_fuse" 39 | addopts = "--cov --cov-report term-missing:skip-covered -v -s" 40 | testpaths = ["tests"] 41 | asyncio_default_fixture_loop_scope = "session" 42 | 43 | [tool.ruff] 44 | target-version = "py310" 45 | line-length = 80 46 | indent-width = 4 47 | 48 | [tool.ruff.lint] 49 | # https://docs.astral.sh/ruff/rules/#legend 50 | select = ["ALL"] 51 | # D10* Missing docstring 52 | ignore = [ 53 | "D10", 54 | "D204", 55 | "D203", 56 | "D211", 57 | "D212", 58 | "D213", 59 | "EM101", 60 | "EM102", 61 | "ERA001", 62 | "FBT002", 63 | "FBT003", 64 | "FIX002", 65 | "PT001", 66 | "PT023", 67 | "PLR0913", 68 | "Q001", 69 | "TD003", 70 | "TRY003", 71 | "COM812", 72 | "ISC001", 73 | "S603", 74 | "S607", 75 | ] 76 | # Allow unused variables when underscore-prefixed. 77 | dummy-variable-rgx = '^(_+|(_+[a-zA-Z0-9_]*[a-zA-Z0-9]+?))$' 78 | 79 | [tool.ruff.lint.flake8-quotes] 80 | inline-quotes = "double" 81 | 82 | [tool.ruff.format] 83 | quote-style = "double" 84 | indent-style = "space" 85 | docstring-code-format = false 86 | 87 | [tool.mypy] 88 | python_version = "3.10" 89 | warn_unused_ignores = true 90 | warn_return_any = true 91 | warn_unreachable = true 92 | strict_equality = true 93 | strict = true 94 | pretty = true 95 | -------------------------------------------------------------------------------- /tests/test_tag.py: -------------------------------------------------------------------------------- 1 | # ruff: noqa: S101 2 | from __future__ import annotations 3 | 4 | import json 5 | from io import BytesIO 6 | from subprocess import check_output 7 | from tempfile import NamedTemporaryFile 8 | 9 | import pytest 10 | 11 | from yandex_fuse.ya_player import ( 12 | MP3_HEADER_MIN_SIZE, 13 | MP4_HEADER_MIN_SIZE, 14 | TrackTag, 15 | ) 16 | 17 | 18 | class TestTag: 19 | def ffprobe_tag(self, data: bytes) -> dict[str, str]: 20 | with NamedTemporaryFile("wb") as tmp_fd: 21 | tmp_fd.write(data) 22 | tmp_fd.flush() 23 | json_output = json.loads( 24 | check_output( 25 | [ 26 | "ffprobe", 27 | "-loglevel", 28 | "panic", 29 | "-print_format", 30 | "json", 31 | "-show_format", 32 | "-show_streams", 33 | tmp_fd.name, 34 | ] 35 | ) 36 | ) 37 | return {k.lower(): v for k, v in json_output["format"]["tags"].items()} 38 | 39 | @pytest.fixture 40 | def tag(self) -> TrackTag: 41 | return TrackTag( 42 | artist="Test_artist", 43 | title="Test_title", 44 | album="Test_album", 45 | year="2024", 46 | genre="Test_genre", 47 | duration_ms=100.0, 48 | ) 49 | 50 | def tagging(self, track_tag: TrackTag, audio: BytesIO, codec: str) -> None: 51 | after_tag = track_tag.to_bytes(audio, codec) 52 | assert after_tag is not None 53 | 54 | audio_tags = self.ffprobe_tag(after_tag) 55 | 56 | tag_json = track_tag.to_dict() 57 | 58 | for key in ("artist", "title", "album", "genre"): 59 | assert audio_tags.get(key, "") == tag_json[key], audio_tags 60 | 61 | assert audio_tags["date"] == tag_json["year"] 62 | 63 | def partial_tagging( 64 | self, 65 | track_tag: TrackTag, 66 | audio: BytesIO, 67 | codec: str, 68 | read_size: int, 69 | min_tag_size: int, 70 | ) -> None: 71 | audio_parital = BytesIO() 72 | while data := audio.read(read_size): 73 | audio_parital.write(data) 74 | last_seek = audio_parital.tell() 75 | 76 | after_tag = track_tag.to_bytes(audio_parital, codec) 77 | if len(audio_parital.getbuffer()) < min_tag_size: 78 | assert after_tag is None 79 | else: 80 | assert after_tag is not None 81 | assert audio_parital.tell() == last_seek 82 | 83 | audio_parital.seek(0) 84 | 85 | assert audio.getvalue() == audio_parital.getvalue() 86 | 87 | def test_tag_mp4(self, tag: TrackTag, m4a_audio: BytesIO) -> None: 88 | self.tagging(tag, m4a_audio, "aac") 89 | 90 | def test_tag_partial_data_mp4( 91 | self, tag: TrackTag, m4a_audio: BytesIO 92 | ) -> None: 93 | self.partial_tagging(tag, m4a_audio, "aac", 49, MP4_HEADER_MIN_SIZE) 94 | 95 | def test_tag_mp3(self, tag: TrackTag, mp3_audio: BytesIO) -> None: 96 | self.tagging(tag, mp3_audio, "mp3") 97 | 98 | def test_tag_partial_data_mp3( 99 | self, tag: TrackTag, mp3_audio: BytesIO 100 | ) -> None: 101 | self.partial_tagging(tag, mp3_audio, "mp3", 1024, MP3_HEADER_MIN_SIZE) 102 | 103 | def test_tag_flac(self, tag: TrackTag, flac_audio: BytesIO) -> None: 104 | self.tagging(tag, flac_audio, "flac") 105 | 106 | def test_tag_partial_data_flac( 107 | self, tag: TrackTag, flac_audio: BytesIO 108 | ) -> None: 109 | self.partial_tagging(tag, flac_audio, "flac", 1024, 9098) 110 | -------------------------------------------------------------------------------- /yandex_fuse/main.py: -------------------------------------------------------------------------------- 1 | import asyncio 2 | import faulthandler 3 | import logging 4 | import os 5 | import socket 6 | from argparse import ArgumentParser, Namespace 7 | from pathlib import Path 8 | 9 | import pyfuse3 10 | import pyfuse3_asyncio # type: ignore[import-untyped] 11 | 12 | from yandex_fuse.ya_music_fs import YaMusicFS 13 | 14 | faulthandler.enable() 15 | log = logging.getLogger(__name__) 16 | 17 | pyfuse3_asyncio.enable() 18 | 19 | 20 | def init_logging(*, debug: bool, systemd_run: bool) -> None: 21 | root_logger = logging.getLogger() 22 | root_logger.setLevel(logging.INFO if not debug else logging.DEBUG) 23 | 24 | logging.getLogger("pyfuse3").setLevel(logging.INFO) 25 | logging.getLogger("yandex_music").setLevel(logging.INFO) 26 | 27 | formatter = logging.Formatter( 28 | "%(asctime)s.%(msecs)03d %(threadName)s: [%(name)s] %(message)s", 29 | datefmt="%Y-%m-%d %H:%M:%S", 30 | ) 31 | 32 | if debug or systemd_run: 33 | handler = logging.StreamHandler() 34 | handler.setFormatter(formatter) 35 | handler.setLevel(logging.INFO if not debug else logging.DEBUG) 36 | root_logger.addHandler(handler) 37 | else: 38 | fh = logging.FileHandler(Path.home().joinpath(".cache/yandex_fuse.log")) 39 | fh.setFormatter(formatter) 40 | fh.setLevel(logging.INFO if not debug else logging.DEBUG) 41 | root_logger.addHandler(fh) 42 | 43 | 44 | def parse_args() -> Namespace: 45 | parser = ArgumentParser() 46 | 47 | parser.add_argument( 48 | "--debug", 49 | action="store_true", 50 | default=False, 51 | help="Enable debugging output", 52 | ) 53 | parser.add_argument( 54 | "--debug-fuse", 55 | action="store_true", 56 | default=False, 57 | help="Enable FUSE debugging output", 58 | ) 59 | parser.add_argument( 60 | "--wait", 61 | action="store_true", 62 | default=False, 63 | help="Run foreground", 64 | ) 65 | parser.add_argument("-o", default="") 66 | parser.add_argument( 67 | "mountpoint", 68 | type=str, 69 | help="Where to mount the file system", 70 | ) 71 | 72 | return parser.parse_args() 73 | 74 | 75 | def main() -> None: 76 | options = parse_args() 77 | notify_addr = os.getenv("NOTIFY_SOCKET") 78 | 79 | init_logging(debug=options.debug, systemd_run=notify_addr is not None) 80 | 81 | fuse_options = set(pyfuse3.default_options) 82 | ya_music_fs = YaMusicFS() 83 | fuse_options.add("fsname=yandex_music") 84 | fuse_options.add("allow_other") 85 | 86 | if options.debug_fuse: 87 | fuse_options.add("debug") 88 | 89 | Path(options.mountpoint).mkdir(exist_ok=True) 90 | if Path(options.mountpoint).is_mount(): 91 | raise ValueError("Is mount.") 92 | 93 | socket_notify = None 94 | if notify_addr is None: 95 | child_pid = os.fork() 96 | else: 97 | child_pid = None 98 | socket_notify = socket.socket(socket.AF_UNIX, socket.SOCK_DGRAM) 99 | if notify_addr[0] == "@": 100 | notify_addr = "\0" + notify_addr[1:] 101 | socket_notify.connect(notify_addr) 102 | 103 | if child_pid: 104 | if options.wait: 105 | os.waitpid(child_pid, 0) 106 | else: 107 | pyfuse3.init(ya_music_fs, options.mountpoint, fuse_options) 108 | loop = asyncio.new_event_loop() 109 | asyncio.set_event_loop(loop) 110 | try: 111 | loop.run_until_complete(ya_music_fs.start()) 112 | if socket_notify is not None: 113 | socket_notify.sendall(b"READY=1") 114 | loop.run_until_complete(pyfuse3.main()) 115 | except Exception: 116 | pyfuse3.close(unmount=True) 117 | raise 118 | finally: 119 | loop.close() 120 | 121 | pyfuse3.close() 122 | 123 | 124 | if __name__ == "__main__": 125 | main() 126 | -------------------------------------------------------------------------------- /tests/test_music_fs.py: -------------------------------------------------------------------------------- 1 | # ruff: noqa: S101 2 | # ruff: noqa: ARG002 3 | # ruff: noqa: ANN001 4 | # ruff: noqa: ANN201 5 | # ruff: noqa: ANN202 6 | # ruff: noqa: ANN204 7 | # mypy: ignore-errors 8 | 9 | from typing import ParamSpec, TypeVar 10 | from unittest import mock 11 | 12 | import pytest 13 | from pytest_mock import MockerFixture 14 | 15 | from yandex_fuse.ya_music_fs import SQLTrack, StreamReader, YaMusicFS 16 | 17 | P = ParamSpec("P") 18 | T = TypeVar("T") 19 | 20 | 21 | class MockResponse: 22 | def __init__(self, text: str, status: int) -> None: 23 | self._text = text 24 | self.status = status 25 | 26 | async def text(self) -> str: 27 | return self._text 28 | 29 | async def __aexit__(self, exc_type, exc, tb): 30 | pass 31 | 32 | async def __aenter__(self): 33 | return self 34 | 35 | 36 | TRACK_INFO = SQLTrack( 37 | name="TestTrack", 38 | inode=10, 39 | track_id=10, 40 | codec="acc", 41 | bitrate=128, 42 | artist="test", 43 | title="test", 44 | album="test", 45 | year="2024", 46 | genre="test", 47 | duration_ms=100, 48 | playlist_id="NOT", 49 | quality="hq", 50 | size=100, 51 | ) 52 | 53 | 54 | @pytest.fixture(autouse="True") 55 | def client_session_mock(): 56 | with mock.patch("yandex_fuse.ya_music_fs.YaMusicFS._client_session") as m: 57 | yield m 58 | 59 | 60 | @mock.patch("yandex_fuse.ya_music_fs.Buffer.download", mock.AsyncMock()) 61 | @mock.patch("yandex_fuse.ya_music_fs.YaMusicFS._ya_player", mock.AsyncMock()) 62 | @pytest.mark.asyncio 63 | class TestMusicFS: 64 | @pytest.fixture(scope="session") 65 | def ya_music_fs(self) -> YaMusicFS: 66 | yandex_music = YaMusicFS 67 | yandex_music.FILE_DB = "file::memory:?cache=shared" 68 | return yandex_music() 69 | 70 | @mock.patch("yandex_fuse.ya_music_fs.YaMusicFS._get_track_by_inode") 71 | @mock.patch("yandex_fuse.ya_music_fs.YaMusicFS.get_or_update_direct_link") 72 | async def test_open( 73 | self, 74 | mock_get_or_update_direct_link: mock.Mock, 75 | mock_get_track_by_inode: mock.Mock, 76 | ya_music_fs: YaMusicFS, 77 | ) -> None: 78 | mock_get_track_by_inode.return_value = TRACK_INFO 79 | file_info = await ya_music_fs.open(519, 0o664, None) 80 | assert file_info.fh == 1 81 | 82 | assert mock_get_or_update_direct_link.call_count == 1 83 | 84 | file_info = await ya_music_fs.open(519, 0o664, None) 85 | assert file_info.fh == 2 # noqa: PLR2004 86 | 87 | async def test_read( 88 | self, 89 | ya_music_fs: YaMusicFS, 90 | mocker: MockerFixture, 91 | ) -> None: 92 | buffer = mock.MagicMock() 93 | 94 | buffer.read_from = mock.AsyncMock() 95 | buffer.read_from.return_value = b"Test" 96 | buffer.total_second.return_value = 0 97 | 98 | stream = StreamReader( 99 | buffer=buffer, 100 | track=TRACK_INFO, 101 | is_send_feedback=False, 102 | ) 103 | mocker.patch.object(ya_music_fs, "_fd_map_stream", {10: stream}) 104 | chunk = await ya_music_fs.read(10, 100, 100) 105 | assert chunk == b"Test" 106 | 107 | @mock.patch("yandex_fuse.virt_fs.VirtFS._get_file_stat_by_inode") 108 | async def test_release( 109 | self, 110 | mock_get_file_stat_by_inode: mock.Mock(), 111 | ya_music_fs: YaMusicFS, 112 | mocker: MockerFixture, 113 | ) -> None: 114 | stream = StreamReader( 115 | buffer=mock.MagicMock(), 116 | track=TRACK_INFO, 117 | is_send_feedback=False, 118 | ) 119 | mocker.patch.object(ya_music_fs, "_fd_map_inode", {10: 519}) 120 | mocker.patch.object(ya_music_fs, "_fd_map_stream", {10: stream}) 121 | 122 | await ya_music_fs.release(10) 123 | 124 | # TODO(vm86): check via mock 125 | assert ya_music_fs._fd_map_inode == {} # noqa: SLF001 126 | assert ya_music_fs._fd_map_stream == {} # noqa: SLF001 127 | -------------------------------------------------------------------------------- /yandex_fuse/request.py: -------------------------------------------------------------------------------- 1 | # https://github.com/AlexxIT/YandexStation/blob/master/custom_components/yandex_station/core/yandex_session.py 2 | from __future__ import annotations 3 | 4 | import asyncio 5 | import json 6 | import re 7 | from typing import TYPE_CHECKING, Any, ParamSpec 8 | 9 | from aiohttp import ClientError, ClientSession 10 | from yandex_music.exceptions import ( # type: ignore[import-untyped] 11 | BadRequestError, 12 | NetworkError, 13 | NotFoundError, 14 | TimedOutError, 15 | UnauthorizedError, 16 | YandexMusicError, 17 | ) 18 | from yandex_music.utils.request_async import ( # type: ignore[import-untyped] 19 | USER_AGENT, 20 | Request, 21 | ) 22 | 23 | if TYPE_CHECKING: 24 | from aiohttp.abc import AbstractCookieJar 25 | 26 | P = ParamSpec("P") 27 | 28 | 29 | class ClientRequest(Request): # type: ignore[misc] 30 | def __init__( 31 | self, 32 | client_session: ClientSession, 33 | *args: P.args, 34 | **kwargs: P.kwargs, 35 | ) -> None: 36 | super().__init__(*args, **kwargs) 37 | self.__client_session = client_session 38 | 39 | @property 40 | def _cookie_jar(self) -> AbstractCookieJar: 41 | return self.__client_session.cookie_jar 42 | 43 | async def _request_wrapper( # noqa: C901 44 | self, method: str, url: str, **kwargs: dict[str, Any] 45 | ) -> bytes: 46 | if "headers" not in kwargs: 47 | kwargs["headers"] = {} 48 | 49 | kwargs["headers"]["User-Agent"] = USER_AGENT 50 | 51 | kwargs.pop("timeout", None) 52 | try: 53 | async with self.__client_session.request( 54 | method, url, **kwargs 55 | ) as _resp: 56 | resp = _resp 57 | content = await resp.content.read() 58 | except asyncio.TimeoutError as e: 59 | raise TimedOutError from e 60 | except ClientError as e: 61 | raise NetworkError(e) from e 62 | 63 | if resp.ok: 64 | return content 65 | 66 | message = "Unknown error" 67 | try: 68 | parse = self._parse(content) 69 | if parse: 70 | message = parse.get_error() 71 | except YandexMusicError: 72 | message = "Unknown HTTPError" 73 | 74 | if resp.status in (401, 403): 75 | raise UnauthorizedError(message) 76 | if resp.status == 400: # noqa: PLR2004 77 | raise BadRequestError(message) 78 | if resp.status == 404: # noqa: PLR2004 79 | raise NotFoundError(message) 80 | if resp.status in (409, 413): 81 | raise NetworkError(message) 82 | 83 | if resp.status == 502: # noqa: PLR2004 84 | raise NetworkError("Bad Gateway") 85 | 86 | raise NetworkError(f"{message} ({resp.status}): {content!r}") 87 | 88 | 89 | class YandexClientRequest(ClientRequest): 90 | def __init__( 91 | self, client_session: ClientSession, *args: P.args, **kwargs: P.kwargs 92 | ) -> None: 93 | super().__init__(client_session, *args, **kwargs) 94 | self._auth_payload: dict[str, str] = {} 95 | 96 | async def get_qr(self) -> str: 97 | # step 1: csrf_token 98 | response_csrf_token = await self._request_wrapper( 99 | "GET", 100 | "https://passport.yandex.ru/am?app_platform=android", 101 | ) 102 | 103 | re_result = re.search( 104 | rb'"csrf_token" value="([^"]+)"', 105 | response_csrf_token, 106 | ) 107 | if re_result is None: 108 | raise RuntimeError("CSRF token not found!") 109 | self._auth_payload = {"csrf_token": re_result[1].decode()} 110 | 111 | # step 2: track_id 112 | response_track_id = await self._request_wrapper( 113 | "POST", 114 | "https://passport.yandex.ru/registration-validations/auth/password/submit", 115 | data={ 116 | **self._auth_payload, 117 | "retpath": "https://passport.yandex.ru/profile", 118 | "with_code": 1, 119 | }, 120 | ) 121 | response_json = json.loads(response_track_id) 122 | if response_json["status"] != "ok": 123 | raise RuntimeError(f"Error login {response_json['errors']}") 124 | self._auth_payload = { 125 | "csrf_token": response_json["csrf_token"], 126 | "track_id": response_json["track_id"], 127 | } 128 | track_id = response_json["track_id"] 129 | return ( 130 | f"https://passport.yandex.ru/auth/magic/code/?track_id={track_id}" 131 | ) 132 | 133 | async def login_qr(self) -> str | None: 134 | response = await self._request_wrapper( 135 | "POST", 136 | "https://passport.yandex.ru/auth/new/magic/status/", 137 | data=self._auth_payload, 138 | ) 139 | response_json = json.loads(response) 140 | if not response_json: 141 | return None 142 | # resp={} if no auth yet 143 | if response_json["status"] != "ok": 144 | raise RuntimeError(f"Error login {response_json['errors']}") 145 | return await self.login_cookies() 146 | 147 | async def login_cookies(self) -> str: 148 | cookies = "; ".join( 149 | [ 150 | f"{c.key}={c.value}" 151 | for c in self._cookie_jar 152 | if c["domain"].endswith("yandex.ru") 153 | ], 154 | ) 155 | # https://gist.github.com/superdima05/04601c6b15d5eeb1c376535579d08a99 156 | response = await self._request_wrapper( 157 | "POST", 158 | "https://mobileproxy.passport.yandex.net/1/bundle/oauth/token_by_sessionid", 159 | data={ 160 | "client_id": "c0ebe342af7d48fbbbfcf2d2eedb8f9e", 161 | "client_secret": "ad0a908f0aa341a182a37ecd75bc319e", 162 | }, 163 | headers={ 164 | "Ya-Client-Host": "passport.yandex.ru", 165 | "Ya-Client-Cookie": cookies, 166 | }, 167 | ) 168 | response_json = json.loads(response) 169 | token: str = response_json["access_token"] 170 | return token 171 | 172 | async def get_music_token(self, x_token: str) -> dict[str, str]: 173 | payload = { 174 | # Thanks to https://github.com/MarshalX/yandex-music-api/ 175 | "client_secret": "53bc75238f0c4d08a118e51fe9203300", 176 | "client_id": "23cabbbdc6cd418abb4b39c32c41195d", 177 | "grant_type": "x-token", 178 | "access_token": x_token, 179 | } 180 | response = await self._request_wrapper( 181 | "POST", 182 | "https://oauth.mobile.yandex.net/1/token", 183 | data=payload, 184 | ) 185 | response_json: dict[str, str] = json.loads(response) 186 | return response_json 187 | -------------------------------------------------------------------------------- /yandex_fuse/ya_player.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | import base64 4 | import hashlib 5 | import hmac 6 | import json 7 | import logging 8 | import struct 9 | import time 10 | import uuid 11 | import webbrowser 12 | from asyncio import sleep 13 | from asyncio.tasks import create_task 14 | from io import BytesIO 15 | from typing import TYPE_CHECKING, Any, ClassVar 16 | 17 | from mutagen import MutagenError # type: ignore[attr-defined] 18 | from mutagen.flac import FLAC, FLACNoHeaderError 19 | from mutagen.id3 import ( # type: ignore[attr-defined] 20 | TALB, 21 | TCON, 22 | TIT2, 23 | TLEN, 24 | TPE1, 25 | TYER, 26 | ) 27 | from mutagen.mp3 import MP3 28 | from mutagen.mp4 import MP4 29 | from yandex_music import ( # type: ignore[import-untyped] 30 | ClientAsync, 31 | Track, 32 | YandexMusicObject, 33 | ) 34 | from yandex_music.utils import model # type: ignore[import-untyped] 35 | from yandex_music.utils.sign_request import ( # type: ignore[import-untyped] 36 | DEFAULT_SIGN_KEY, 37 | ) 38 | 39 | from yandex_fuse.request import YandexClientRequest 40 | 41 | if TYPE_CHECKING: 42 | from collections.abc import AsyncGenerator 43 | from pathlib import Path 44 | 45 | from aiohttp import ClientSession 46 | 47 | log = logging.getLogger(__name__) 48 | 49 | 50 | MP4_HEADER_CHUNK_SIZE = 8 51 | MP3_HEADER_MIN_SIZE = 16000 52 | MP4_HEADER_MIN_SIZE = 62835 53 | 54 | 55 | @model 56 | class TrackTag(YandexMusicObject): # type: ignore[misc] 57 | title: str 58 | duration_ms: int 59 | artist: str = "" 60 | album: str = "" 61 | year: str = "" 62 | genre: str = "" 63 | size: int = 0 64 | 65 | def __post_init__(self) -> None: 66 | for key, value in self.__dict__.copy().items(): 67 | if isinstance(value, str): 68 | self.__dict__[key] = value.strip() 69 | 70 | @classmethod 71 | def from_json(cls, data: dict[str, int | str]) -> TrackTag: 72 | return cls( 73 | **{ 74 | key: data[key] 75 | for key in data 76 | if key in cls.__dataclass_fields__ 77 | }, 78 | ) 79 | 80 | def _to_mp4_tag(self, stream: BytesIO) -> bytes | None: 81 | tag = {} 82 | tag_stream = BytesIO() 83 | 84 | while data := stream.read(MP4_HEADER_CHUNK_SIZE): 85 | if len(data) != MP4_HEADER_CHUNK_SIZE: 86 | break 87 | atom_length, atom_name = struct.unpack(">I4s", data) 88 | tag[atom_name] = (atom_length, stream.tell()) 89 | if atom_name == b"mdat": 90 | break 91 | tag_stream.write(data) 92 | tag_stream.write(stream.read(atom_length - MP4_HEADER_CHUNK_SIZE)) 93 | if {b"mdat", b"moov"} & tag.keys() != {b"mdat", b"moov"}: 94 | return None 95 | 96 | audiofile = MP4(fileobj=tag_stream) # type: ignore[no-untyped-call] 97 | audiofile.pop("----:com.apple.iTunes:iTunSMPB", None) # type: ignore[no-untyped-call] 98 | 99 | # https://mutagen.readthedocs.io/en/latest/api/mp4.html#mutagen.mp4.MP4Tags 100 | audiofile["\xa9nam"] = self.title 101 | audiofile["\xa9alb"] = self.album 102 | audiofile["\xa9ART"] = self.artist 103 | audiofile["\xa9day"] = self.year 104 | audiofile["\xa9gen"] = self.genre 105 | 106 | stream.seek(0) 107 | audiofile.save(fileobj=tag_stream) # type: ignore[no-untyped-call] 108 | 109 | stream.seek(0) 110 | tag_stream.seek(0) 111 | 112 | new_stream = BytesIO() 113 | while data := tag_stream.read(MP4_HEADER_CHUNK_SIZE): 114 | atom_length, atom_name = struct.unpack(">I4s", data) 115 | if atom_name == b"moov": 116 | old_atom_length, _ = tag[b"moov"] 117 | self.size = atom_length - old_atom_length 118 | 119 | new_stream.write(data) 120 | new_stream.write( 121 | tag_stream.read(atom_length - MP4_HEADER_CHUNK_SIZE) 122 | ) 123 | 124 | atom_length, seek = tag[b"mdat"] 125 | stream.seek(seek - MP4_HEADER_CHUNK_SIZE) 126 | new_stream.write(stream.read()) 127 | return bytes(new_stream.getbuffer()) 128 | 129 | def _to_mp3_tag(self, stream: BytesIO) -> bytes | None: 130 | new_stream = BytesIO() 131 | new_stream.write(stream.read()) 132 | 133 | audiofile = MP3(fileobj=new_stream) 134 | audiofile.delete(fileobj=new_stream) 135 | if audiofile.tags is None: 136 | audiofile.add_tags() # type: ignore[no-untyped-call] 137 | 138 | audiofile["TIT2"] = TIT2(encoding=3, text=self.title) # type: ignore[no-untyped-call] 139 | audiofile["TPE1"] = TPE1(encoding=3, text=self.artist) # type: ignore[no-untyped-call] 140 | audiofile["TALB"] = TALB(encoding=3, text=self.album) # type: ignore[no-untyped-call] 141 | audiofile["TYE"] = TYER(encoding=3, text=self.year) # type: ignore[no-untyped-call] 142 | audiofile["TCON"] = TCON(encoding=3, text=self.genre) # type: ignore[no-untyped-call] 143 | audiofile["TLEN"] = TLEN(encoding=3, text=str(self.duration_ms)) # type: ignore[no-untyped-call] 144 | 145 | new_stream.seek(0) 146 | 147 | audiofile.save(fileobj=new_stream) 148 | 149 | self.size = len(new_stream.getbuffer()) - len(stream.getbuffer()) 150 | return bytes(new_stream.getbuffer()) 151 | 152 | def _to_flac_tag(self, stream: BytesIO) -> bytes | None: 153 | new_stream = BytesIO() 154 | new_stream.write(stream.read()) 155 | new_stream.seek(0) 156 | try: 157 | audiofile = FLAC(fileobj=new_stream) 158 | except (FLACNoHeaderError, MutagenError): 159 | return None 160 | # https://exiftool.org/TagNames/Vorbis.html 161 | 162 | audiofile["TITLE"] = self.title 163 | audiofile["ALBUM"] = self.album 164 | audiofile["ARTIST"] = self.artist 165 | audiofile["DATE"] = self.year 166 | audiofile["GENRE"] = self.genre 167 | new_stream.seek(0) 168 | 169 | audiofile.save(fileobj=new_stream) 170 | self.size = len(new_stream.getbuffer()) - len(stream.getbuffer()) 171 | return bytes(new_stream.getbuffer()) 172 | 173 | def to_bytes(self, stream: BytesIO, codec: str) -> bytes | None: 174 | buffer = bytearray(stream.getbuffer()) 175 | 176 | current_offset = stream.tell() 177 | stream.seek(0) 178 | 179 | try: 180 | if codec == "flac" and FLAC.score(".flac", None, buffer): # type: ignore[no-untyped-call] 181 | return self._to_flac_tag(stream) 182 | 183 | if codec == "mp3": 184 | if len(buffer) <= MP3_HEADER_MIN_SIZE: 185 | return None 186 | if MP3.score("", None, buffer): # type: ignore[no-untyped-call] 187 | return self._to_mp3_tag(stream) 188 | elif codec == "aac": 189 | if len(buffer) <= MP4_HEADER_MIN_SIZE: 190 | return None 191 | if MP4.score(None, None, buffer): # type: ignore[no-untyped-call] 192 | return self._to_mp4_tag(stream) 193 | else: 194 | pass 195 | finally: 196 | stream.seek(current_offset) 197 | return bytes(stream.getbuffer()) 198 | 199 | 200 | @model 201 | class ExtendTrack(Track): # type: ignore[misc] 202 | station_id: str = "" 203 | batch_id: int = 0 204 | size: int = 0 205 | codec: str = "" 206 | bitrate_in_kbps: int = 0 207 | quality: str = "" 208 | direct_link: str = "" 209 | 210 | _tag: TrackTag | None = None 211 | 212 | @classmethod 213 | def from_track(cls, track: Track) -> ExtendTrack: 214 | return cls( 215 | **{ 216 | key: track.__dict__[key] 217 | for key in track.__dict__ 218 | if key in cls.__dataclass_fields__ 219 | }, 220 | ) 221 | 222 | @property 223 | def save_name(self) -> str: 224 | artists = ", ".join(self.artists_name()) 225 | name = f"{artists} - {self.title}" 226 | 227 | codec2ext_name = {"flac": "flac", "aac": "m4a", "mp3": "mp3"} 228 | 229 | if self.version: 230 | name += f" ({self.version})" 231 | 232 | ext_name = codec2ext_name.get(self.codec, "unknown") 233 | return f"{name}.{ext_name}".replace("/", "-") 234 | 235 | async def _download_image(self) -> str: 236 | for size in (600, 400, 300, 200): 237 | cover_bytes = await self.download_cover_bytes_async( 238 | size=f"{size}x{size}", 239 | ) 240 | 241 | if not cover_bytes: 242 | continue 243 | log.debug("Cover download %s / %d", self.title, size) 244 | 245 | return base64.b64encode(cover_bytes).decode("ascii") 246 | return "" 247 | 248 | @property 249 | def tag(self) -> TrackTag: 250 | if self._tag: 251 | return self._tag 252 | self._tag = TrackTag( 253 | artist=", ".join(self.artists_name()), 254 | title=self.title or "-", 255 | duration_ms=self.duration_ms, 256 | ) 257 | for album in self.albums: 258 | if self._tag.album: 259 | self._tag.album += ", " 260 | self._tag.album += album.title 261 | 262 | if not self._tag.year and album.year: 263 | self._tag.year = str(album.year) 264 | if not self._tag.genre and album.genre: 265 | self._tag.genre = album.genre 266 | 267 | return self._tag 268 | 269 | 270 | @model 271 | class DownloadInfo(YandexMusicObject): # type: ignore[misc] 272 | quality: str 273 | codec: str 274 | urls: list[str] 275 | url: str 276 | bitrate: int 277 | track_id: str 278 | size: int 279 | transport: str 280 | gain: bool 281 | real_id: str 282 | 283 | 284 | class YandexMusicPlayer(ClientAsync): # type: ignore[misc] 285 | _default_settings: ClassVar = { 286 | "token": None, 287 | "last_track": None, 288 | "from_id": f"music-{uuid.uuid4()}", 289 | "station_id": "user:onyourwave", 290 | "quality": "lossless", 291 | "blacklist": [], 292 | } 293 | 294 | def __init__( 295 | self, 296 | settings_path: Path, 297 | client_session: ClientSession, 298 | ) -> None: 299 | self.__last_track: str = "" 300 | self.__last_station_id: tuple[str, str] = ("", "") 301 | self.__settings: dict[str, Any] = {} 302 | 303 | self.__settings_path = settings_path 304 | try: 305 | self.__settings = { 306 | **self._default_settings, 307 | **json.loads(self.__settings_path.read_text()), 308 | } 309 | self.__last_track = self.__settings["last_track"] 310 | except FileNotFoundError: 311 | self.__settings = self._default_settings 312 | self.save_settings() 313 | 314 | super().__init__( 315 | self.__settings["token"], 316 | request=YandexClientRequest(client_session), 317 | ) 318 | 319 | self.__init_task = None 320 | if not self.__settings["token"]: 321 | self.__init_task = create_task( 322 | self._init_token(), 323 | name="init-token", 324 | ) 325 | 326 | async def _init_token(self) -> None: 327 | qr_link = await self._request.get_qr() 328 | webbrowser.open_new_tab(qr_link) 329 | response = None 330 | while response is None: 331 | response = await self._request.login_qr() 332 | if response: 333 | break 334 | await sleep(5) 335 | token_info = await self._request.get_music_token(response) 336 | self.__settings["token"] = token_info["access_token"] 337 | self._request.set_authorization(self.__settings["token"]) 338 | self.save_settings() 339 | log.info("Token saved.") 340 | if self.__init_task is not None: 341 | init_task = self.__init_task 342 | self.__init_task = None 343 | init_task.cancel() 344 | 345 | def save_settings(self) -> None: 346 | self.__settings_path.write_text(json.dumps(self.__settings)) 347 | 348 | @property 349 | def settings(self) -> dict[str, Any]: 350 | return self.__settings 351 | 352 | @property 353 | def is_init(self) -> bool: 354 | return self.__settings["token"] is not None 355 | 356 | @property 357 | def _from_id(self) -> str: 358 | result: str = self.__settings["from_id"] 359 | return result 360 | 361 | async def load_tracks( 362 | self, 363 | tracks: list[Track], 364 | *, 365 | exclude_track_ids: set[str], 366 | ) -> AsyncGenerator[ExtendTrack, None]: 367 | for track in tracks: 368 | if not track.available: 369 | continue 370 | if str(track.id) in exclude_track_ids: 371 | continue 372 | extend_track = ExtendTrack.from_track(track) 373 | await self._choose_best_dowanload_info(extend_track) 374 | 375 | yield extend_track 376 | 377 | async def next_tracks( 378 | self, 379 | station_id: str, 380 | *, 381 | count: int = 50, 382 | exclude_track_ids: set[str], 383 | ) -> AsyncGenerator[ExtendTrack, None]: 384 | if station_id is None: 385 | raise ValueError("Station is not select!") 386 | 387 | tracks: set[str] = set() 388 | 389 | while len(tracks) < count: 390 | station_tracks = await self.rotor_station_tracks( 391 | station=station_id, 392 | queue=self.__last_track, 393 | ) 394 | 395 | if self.__last_station_id != (station_id, station_tracks.batch_id): 396 | self.__last_station_id = (station_id, station_tracks.batch_id) 397 | await self.rotor_station_feedback_radio_started( 398 | station_id, 399 | self._from_id, 400 | station_tracks.batch_id, 401 | time.time(), 402 | ) 403 | 404 | for sequence_track in station_tracks.sequence: 405 | if sequence_track.track is None: 406 | continue 407 | extend_track = ExtendTrack.from_track(sequence_track.track) 408 | extend_track.station_id = station_id 409 | extend_track.batch_id = station_tracks.batch_id 410 | 411 | if extend_track.save_name in tracks: 412 | continue 413 | 414 | if extend_track.tag.genre in self.settings["blacklist"]: 415 | log.warning( 416 | "Track %s/%s in BLACKLIST", 417 | extend_track.save_name, 418 | extend_track.tag.genre, 419 | ) 420 | await self.feedback_track( 421 | extend_track.track_id, 422 | "skip", 423 | station_id, 424 | station_tracks.batch_id, 425 | 0, 426 | ) 427 | 428 | continue 429 | 430 | first_track = station_tracks.sequence[0].track 431 | await self.feedback_track( 432 | first_track.track_id, 433 | "trackStarted", 434 | station_id, 435 | station_tracks.batch_id, 436 | 0, 437 | ) 438 | 439 | last_track = station_tracks.sequence[-1].track 440 | await self.feedback_track( 441 | last_track.track_id, 442 | "trackFinished", 443 | station_id, 444 | station_tracks.batch_id, 445 | last_track.duration_ms / 1000, 446 | ) 447 | self.__last_track = last_track.track_id 448 | 449 | if extend_track.track_id in exclude_track_ids: 450 | continue 451 | await self._choose_best_dowanload_info(extend_track) 452 | tracks.add(extend_track.save_name) 453 | yield extend_track 454 | 455 | if self.__settings["last_track"] != self.__last_track: 456 | self.__settings["last_track"] = self.__last_track 457 | self.save_settings() 458 | 459 | def get_last_station_info(self) -> tuple[str, str]: 460 | return self.__last_station_id 461 | 462 | async def _get_download_info(self, track_id: str) -> DownloadInfo: 463 | # https://github.com/MarshalX/yandex-music-api/issues/656#issuecomment-2466722441 464 | timestamp = int(time.time()) 465 | params = { 466 | "ts": timestamp, 467 | "trackId": track_id, 468 | "quality": self.__settings["quality"], 469 | "codecs": "flac,aac,he-aac,mp3", 470 | "transports": "raw", 471 | } 472 | res = "".join(str(e) for e in params.values()).replace(",", "") 473 | hmac_sign = hmac.new( 474 | DEFAULT_SIGN_KEY.encode(), 475 | res.encode(), 476 | hashlib.sha256, 477 | ) 478 | sign = base64.b64encode(hmac_sign.digest()).decode()[:-1] 479 | params["sign"] = sign 480 | 481 | resp: dict[str, Any] = await self._request.get( 482 | "https://api.music.yandex.net/get-file-info", params=params 483 | ) 484 | return DownloadInfo(**resp["download_info"]) 485 | 486 | async def _choose_best_dowanload_info(self, track: ExtendTrack) -> None: 487 | download_info = await self._get_download_info(track.id) 488 | 489 | track.codec = download_info.codec 490 | track.bitrate_in_kbps = download_info.bitrate 491 | track.quality = download_info.quality 492 | 493 | async def get_download_links( 494 | self, 495 | track_id: str, 496 | codec: str, 497 | bitrate_in_kbps: int, 498 | ) -> list[str] | None: 499 | download_info = await self._get_download_info(track_id) 500 | log.debug("Track %s, download info: %r", track_id, download_info) 501 | if bitrate_in_kbps != download_info.bitrate: 502 | log.warning( 503 | "Track %s, bitrate not match: %d != %d", 504 | track_id, 505 | bitrate_in_kbps, 506 | download_info.bitrate, 507 | ) 508 | return None 509 | if codec != download_info.codec: 510 | log.warning( 511 | "Track %s, codec not match: %d != %d", 512 | track_id, 513 | codec, 514 | download_info.codec, 515 | ) 516 | return None 517 | 518 | return download_info.urls 519 | 520 | async def feedback_track( 521 | self, 522 | track_id: str, 523 | feedback: str, 524 | station_id: str, 525 | batch_id: str, 526 | total_played_seconds: int, 527 | ) -> None: 528 | # trackStarted, trackFinished, skip. 529 | log.debug( 530 | "Feedback send, track id: %s, feedback: %s, " 531 | "station id: %s, batch_id: %s, seconds %d", 532 | track_id, 533 | feedback, 534 | station_id, 535 | batch_id, 536 | total_played_seconds, 537 | ) 538 | try: 539 | await self.rotor_station_feedback( 540 | station=station_id, 541 | type_=feedback, 542 | timestamp=time.time(), 543 | from_=self._from_id, 544 | batch_id=batch_id, 545 | total_played_seconds=total_played_seconds, 546 | track_id=track_id, 547 | ) 548 | except Exception: 549 | log.exception("Error send feedback:") 550 | -------------------------------------------------------------------------------- /yandex_fuse/virt_fs.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | import errno 4 | import json 5 | import logging 6 | import os 7 | import sqlite3 8 | import stat 9 | import sys 10 | import time 11 | from collections import defaultdict 12 | from contextlib import contextmanager, suppress 13 | from dataclasses import dataclass 14 | from typing import TYPE_CHECKING, Any, ParamSpec, TypeVar 15 | 16 | from pyfuse3 import ( 17 | ROOT_INODE, 18 | EntryAttributes, 19 | FileHandleT, 20 | FileInfo, 21 | FileNameT, 22 | FlagT, 23 | FUSEError, 24 | InodeT, 25 | ModeT, 26 | Operations, 27 | ReaddirToken, 28 | RequestContext, 29 | StatvfsData, 30 | XAttrNameT, 31 | invalidate_inode, 32 | readdir_reply, 33 | ) 34 | 35 | if TYPE_CHECKING: 36 | from collections.abc import ( 37 | Callable, 38 | Coroutine, 39 | Iterator, 40 | Sequence, 41 | ) 42 | 43 | log = logging.getLogger(__name__) 44 | 45 | 46 | class FileStat(EntryAttributes): 47 | def __init__(self) -> None: 48 | self.st_mode = ModeT(0) 49 | self.st_ino = InodeT(0) 50 | self.st_dev = 0 51 | self.st_nlink = 0 52 | self.st_uid = 0 53 | self.st_gid = 0 54 | self.st_size = 0 55 | self.st_atime = 0 56 | self.st_mtime = 0 57 | self.st_ctime = 0 58 | 59 | 60 | P = ParamSpec("P") 61 | T = TypeVar("T") 62 | 63 | ROW_DICT_TYPE = dict[str, Any] | None 64 | 65 | 66 | # Если возникает исключение в FUSE, то вывоз зависает. 67 | def fail_is_exit( 68 | func: Callable[..., Coroutine[Any, Any, T]], 69 | ) -> Callable[..., Coroutine[Any, Any, T]]: 70 | async def wrapped(*args: P.args, **kwargs: P.kwargs) -> T: 71 | try: 72 | return await func(*args, **kwargs) 73 | except FUSEError as error: 74 | log.debug("FUSEError %r: %s", func, str(error)) 75 | raise 76 | except Exception: 77 | log.exception("Error %r", func) 78 | sys.exit(42) 79 | 80 | return wrapped 81 | 82 | 83 | @dataclass 84 | class SQLRow: 85 | __tablename__ = "" 86 | 87 | def insert(self) -> tuple[str, dict[str, Any]]: 88 | data = self.__dict__ 89 | if data.get("id") is None: 90 | data.pop("id", None) 91 | 92 | columns = ", ".join(data.keys()) 93 | placeholders = ":" + ", :".join(data.keys()) 94 | query = f""" 95 | INSERT INTO {self.__tablename__} 96 | ({columns}) 97 | VALUES 98 | ({placeholders}) 99 | """ 100 | return query, data 101 | 102 | @classmethod 103 | def from_row(cls: type[T], row: dict[str, Any] | None) -> T | None: 104 | if row is None: 105 | return None 106 | return cls(**row) 107 | 108 | 109 | @dataclass 110 | class Inode(SQLRow): 111 | __tablename__ = "inodes" 112 | 113 | uid: int 114 | gid: int 115 | mode: ModeT 116 | mtime_ns: int 117 | atime_ns: int 118 | ctime_ns: int 119 | target: bytes 120 | size: int = 0 121 | rdev: int = 0 122 | 123 | id: int | None = None 124 | 125 | 126 | @dataclass 127 | class Dentry(SQLRow): 128 | __tablename__ = "dentrys" 129 | 130 | name: bytes 131 | inode: InodeT 132 | parent_inode: InodeT 133 | rowid: int | None = None 134 | data: bytes = b"" 135 | 136 | 137 | class VirtFS(Operations): 138 | FILE_DB = "file::memory:?cache=shared" 139 | 140 | CREATE_TABLE_QUERYS: tuple[str, ...] = ( 141 | """ 142 | PRAGMA foreign_keys=ON; 143 | """, 144 | """ 145 | CREATE TABLE IF NOT EXISTS inodes ( 146 | id INTEGER PRIMARY KEY, 147 | uid INT NOT NULL, 148 | gid INT NOT NULL, 149 | mode INT NOT NULL, 150 | mtime_ns INT NOT NULL, 151 | atime_ns INT NOT NULL, 152 | ctime_ns INT NOT NULL, 153 | target BLOB(256), 154 | size INT NOT NULL DEFAULT 0, 155 | rdev INT NOT NULL DEFAULT 0 156 | ); 157 | """, 158 | """ 159 | CREATE TABLE IF NOT EXISTS dentrys ( 160 | rowid INTEGER PRIMARY KEY AUTOINCREMENT, 161 | name BLOB(256) NOT NULL, 162 | inode INT NOT NULL 163 | REFERENCES inodes(id) ON DELETE CASCADE, 164 | parent_inode INT NOT NULL 165 | REFERENCES inodes(id) ON DELETE RESTRICT, 166 | data BLOB, 167 | UNIQUE (name, parent_inode) 168 | ); 169 | """, 170 | """ 171 | INSERT OR IGNORE INTO inodes VALUES (1,0,0,16877,0,0,0,X'',0,0); 172 | """, 173 | """ 174 | INSERT OR IGNORE INTO dentrys VALUES(1,X'2e2e',1,1,X''); 175 | """, 176 | ) 177 | 178 | ROOT_INODE = ROOT_INODE 179 | 180 | def __init__(self) -> None: 181 | super().__init__() 182 | 183 | self._db = sqlite3.connect( 184 | self.FILE_DB, isolation_level="IMMEDIATE", uri=True 185 | ) 186 | self._db.row_factory = sqlite3.Row 187 | 188 | self._fd_map_inode: dict[int, InodeT] = {} 189 | self._nlookup: dict[InodeT, int] = defaultdict(int) 190 | 191 | self.__fd_token_read: dict[InodeT, list[tuple[bytes, InodeT]]] = ( 192 | defaultdict(list) 193 | ) 194 | self.__later_invalidate_inode: set[InodeT] = set() 195 | 196 | self._open_cur = 0 197 | self.__init_table() 198 | 199 | @contextmanager 200 | def _db_cursor(self) -> Iterator[sqlite3.Cursor]: 201 | self._open_cur += 1 202 | try: 203 | with self._db: 204 | yield self._db.cursor() 205 | finally: 206 | self._open_cur -= 1 207 | 208 | def __init_table(self) -> None: 209 | with self._db_cursor() as cur: 210 | log.debug("Init database.") 211 | 212 | for create_table_query in self.CREATE_TABLE_QUERYS: 213 | cur.execute(create_table_query) 214 | 215 | def _get_int_row(self, query: str, *params: tuple[str | int, ...]) -> int: 216 | with self._db_cursor() as cur: 217 | cur.execute(query, *params) 218 | result = cur.fetchone() 219 | if result: 220 | return int(*result) 221 | return 0 222 | 223 | def _get_dict_row( 224 | self, query: str, *params: tuple[Any, ...] 225 | ) -> ROW_DICT_TYPE: 226 | with self._db_cursor() as cur: 227 | cur.execute(query, *params) 228 | result = cur.fetchone() 229 | if result: 230 | return dict(result) 231 | return None 232 | 233 | def _get_list_row( 234 | self, query: str, *params: tuple[str | int, ...] 235 | ) -> list[dict[str, Any]]: 236 | with self._db_cursor() as cur: 237 | cur.execute(query, *params) 238 | return [dict(row) for row in cur.fetchall()] 239 | 240 | def _get_fd(self) -> int: 241 | try: 242 | return max(self._fd_map_inode.keys()) + 1 243 | except ValueError: 244 | return 1 245 | 246 | @property 247 | def _inode_map_fd(self) -> dict[InodeT, set[int]]: 248 | result = defaultdict(set) 249 | for fd, inode in self._fd_map_inode.items(): 250 | result[inode].add(fd) 251 | return result 252 | 253 | def _get_file_stat_by_inode(self, inode: InodeT) -> FileStat: 254 | inode_row = Inode.from_row( 255 | self._get_dict_row("SELECT * FROM inodes WHERE id=?", (inode,)) 256 | ) 257 | 258 | if inode_row is None: 259 | raise FUSEError(errno.ENOENT) 260 | 261 | entry = FileStat() 262 | 263 | entry.st_ino = inode 264 | entry.generation = 0 265 | entry.entry_timeout = 300 266 | entry.attr_timeout = 3000 267 | 268 | entry.st_mode = inode_row.mode 269 | 270 | entry.st_nlink = self._get_int_row( 271 | "SELECT COUNT(inode) FROM dentrys WHERE inode=?", (inode,) 272 | ) 273 | 274 | entry.st_uid = os.getuid() 275 | entry.st_gid = os.getgid() 276 | entry.st_rdev = 0 277 | entry.st_size = inode_row.size 278 | entry.st_blksize = 512 279 | entry.st_blocks = entry.st_size // entry.st_blksize 280 | 281 | entry.st_atime_ns = inode_row.atime_ns 282 | entry.st_mtime_ns = inode_row.mtime_ns 283 | entry.st_ctime_ns = inode_row.ctime_ns 284 | return entry 285 | 286 | def _get_inode_by_name( 287 | self, parent_inode: InodeT, name: bytes 288 | ) -> InodeT | None: 289 | str_name = name.replace(b"\\", b"") 290 | inode: InodeT | None = None 291 | 292 | if str_name == b".": 293 | return parent_inode 294 | 295 | if str_name == b"..": 296 | dentry = Dentry.from_row( 297 | self._get_dict_row( 298 | "SELECT * FROM dentrys WHERE inode=?", (parent_inode,) 299 | ) 300 | ) 301 | if dentry is not None: 302 | return dentry.parent_inode 303 | dentry = Dentry.from_row( 304 | self._get_dict_row( 305 | "SELECT * FROM dentrys WHERE name=? AND parent_inode=?", 306 | ( 307 | name, 308 | parent_inode, 309 | ), 310 | ) 311 | ) 312 | if dentry: 313 | log.debug("Inode %s by name %s", inode, str_name.decode()) 314 | return dentry.inode 315 | log.debug("Inode by name %s not found.", str_name.decode()) 316 | return None 317 | 318 | @property 319 | def queue_later_invalidate_inode(self) -> set[InodeT]: 320 | return self.__later_invalidate_inode 321 | 322 | def _invalidate_inode(self, inode: InodeT) -> None: 323 | self.__later_invalidate_inode.discard(inode) 324 | if inode not in self._nlookup: 325 | return 326 | if inode in self._inode_map_fd: 327 | log.warning( 328 | "Invalidate inode %d skip. There are open descriptors.", 329 | inode, 330 | ) 331 | self.__later_invalidate_inode.add(inode) 332 | return 333 | with suppress(OSError): 334 | invalidate_inode(inode) 335 | 336 | def _create( 337 | self, 338 | *, 339 | parent_inode: InodeT, 340 | name: bytes, 341 | size: int, 342 | mode: int, 343 | target: bytes, 344 | db_cursor: sqlite3.Cursor, 345 | ) -> InodeT: 346 | now_ns = int(time.time() * 1e9) 347 | 348 | inode_object = Inode( 349 | mode=ModeT(mode), 350 | target=target, 351 | uid=0, 352 | gid=0, 353 | size=size, 354 | mtime_ns=now_ns, 355 | atime_ns=now_ns, 356 | ctime_ns=now_ns, 357 | ) 358 | db_cursor.execute(*inode_object.insert()) 359 | if db_cursor.lastrowid is None: 360 | raise RuntimeError("Lastrowid is none!") 361 | 362 | inode = InodeT(db_cursor.lastrowid) 363 | dentry_object = Dentry( 364 | parent_inode=parent_inode, 365 | inode=inode, 366 | name=name, 367 | ) 368 | db_cursor.execute(*dentry_object.insert()) 369 | if stat.S_ISDIR(inode_object.mode): 370 | dentry_object_dot_dot = Dentry( 371 | parent_inode=inode, 372 | inode=inode, 373 | name=b"..", 374 | ) 375 | db_cursor.execute(*dentry_object_dot_dot.insert()) 376 | 377 | return inode 378 | 379 | def remove(self, parent_inode: InodeT, inode: InodeT) -> bool: 380 | with self._db_cursor() as cur: 381 | cur.execute( 382 | "DELETE FROM dentrys WHERE inode=? AND parent_inode=?", 383 | (inode, parent_inode), 384 | ) 385 | 386 | try: 387 | st_link = self._get_file_stat_by_inode(inode).st_nlink 388 | except FUSEError: 389 | st_link = 0 390 | 391 | if st_link == 0 and inode not in self._inode_map_fd: 392 | cur.execute("DELETE FROM inodes WHERE id=?", (inode,)) 393 | return True 394 | return False 395 | 396 | @fail_is_exit 397 | async def getattr( 398 | self, 399 | inode: InodeT, 400 | ctx: RequestContext, # noqa: ARG002 401 | ) -> EntryAttributes: 402 | return self._get_file_stat_by_inode(inode) 403 | 404 | @fail_is_exit 405 | async def lookup( 406 | self, 407 | parent_inode: InodeT, 408 | name: FileNameT, 409 | ctx: RequestContext, 410 | ) -> EntryAttributes: 411 | inode = self._get_inode_by_name(parent_inode, name) 412 | if inode is None: 413 | raise FUSEError(errno.ENOENT) 414 | self._nlookup[inode] += 1 415 | 416 | return await self.getattr(inode, ctx) 417 | 418 | @fail_is_exit 419 | async def forget(self, inode_list: Sequence[tuple[InodeT, int]]) -> None: 420 | for inode, count in inode_list: 421 | if inode not in self._nlookup: 422 | continue 423 | self._nlookup[inode] -= count 424 | if self._nlookup[inode] <= 0: 425 | self._nlookup.pop(inode, 0) 426 | 427 | @fail_is_exit 428 | async def opendir(self, inode: InodeT, ctx: RequestContext) -> FileHandleT: # noqa: ARG002 429 | fd = self._get_fd() 430 | self._fd_map_inode[fd] = inode 431 | return FileHandleT(fd) 432 | 433 | @fail_is_exit 434 | async def releasedir(self, fd: FileHandleT) -> None: 435 | inode = self._fd_map_inode.pop(fd) 436 | self.__fd_token_read.pop(inode, None) 437 | if inode in self.__later_invalidate_inode: 438 | self._invalidate_inode(inode) 439 | 440 | @fail_is_exit 441 | async def statfs(self, ctx: RequestContext) -> StatvfsData: # noqa: ARG002 442 | stat = StatvfsData() 443 | 444 | stat.f_bsize = 512 445 | stat.f_frsize = 512 446 | 447 | max_size = 2**40 448 | size = self._get_int_row("SELECT SUM(size) FROM inodes") 449 | 450 | stat.f_blocks = max_size // stat.f_bsize 451 | stat.f_bfree = (max_size - size) // stat.f_frsize 452 | stat.f_bavail = stat.f_bfree 453 | 454 | max_inodes = 2**32 455 | inodes = self._get_int_row("SELECT COUNT(id) FROM inodes") 456 | 457 | stat.f_files = max_inodes 458 | stat.f_ffree = max_inodes - inodes 459 | stat.f_favail = stat.f_ffree 460 | 461 | return stat 462 | 463 | @fail_is_exit 464 | async def readdir( 465 | self, fd: int, start_id: int, token: ReaddirToken 466 | ) -> None: 467 | dir_inode = self._fd_map_inode[fd] 468 | 469 | if dir_inode in self.__fd_token_read: 470 | dir_list = self.__fd_token_read[dir_inode] 471 | else: 472 | result = [] 473 | names = set() 474 | with self._db_cursor() as cur: 475 | cur.execute( 476 | """ 477 | SELECT 478 | * 479 | FROM dentrys 480 | WHERE parent_inode = ? 481 | """, 482 | (dir_inode,), 483 | ) 484 | for row in cur.fetchall(): 485 | name = row["name"] 486 | if name in names: 487 | continue 488 | if isinstance(name, str): 489 | name = name.encode() 490 | 491 | names.add(name) 492 | result.append((name, row["inode"])) 493 | 494 | self.__fd_token_read[dir_inode] = dir_list = [ 495 | (b".", InodeT(1)), 496 | *result, 497 | ] 498 | 499 | i = start_id + 1 or 0 500 | 501 | dir_list_slice = dir_list[i - 1 :] 502 | dir_list_slice.reverse() 503 | while len(dir_list_slice) > 0: 504 | name, inode = dir_list_slice.pop() 505 | attr = self._get_file_stat_by_inode(inode) 506 | 507 | if not readdir_reply( 508 | token, 509 | name, 510 | attr, 511 | i, 512 | ): 513 | return 514 | i += 1 515 | self._nlookup[inode] += 1 516 | 517 | @fail_is_exit 518 | async def open( 519 | self, 520 | inode: InodeT, 521 | flags: FlagT, # noqa: ARG002 522 | ctx: RequestContext, # noqa: ARG002 523 | ) -> FileInfo: 524 | fd = self._get_fd() 525 | self._fd_map_inode[fd] = inode 526 | 527 | return FileInfo(fh=FileHandleT(fd)) 528 | 529 | @fail_is_exit 530 | async def release(self, fd: int) -> None: 531 | inode = self._fd_map_inode.pop(fd) 532 | if self._get_file_stat_by_inode(inode).st_nlink == 0: 533 | with self._db_cursor() as cur: 534 | cur.execute("DELETE FROM inodes WHERE id=?", (inode,)) 535 | if inode in self.__later_invalidate_inode: 536 | self._invalidate_inode(inode) 537 | 538 | @fail_is_exit 539 | async def unlink( 540 | self, 541 | parent_inode: InodeT, 542 | name: FileNameT, 543 | ctx: RequestContext, 544 | ) -> None: 545 | entry = await self.lookup(parent_inode, name, ctx) 546 | 547 | if stat.S_ISDIR(entry.st_mode): 548 | raise FUSEError(errno.EISDIR) 549 | 550 | self.remove(parent_inode, entry.st_ino) 551 | 552 | @fail_is_exit 553 | async def rmdir( 554 | self, 555 | parent_inode: InodeT, 556 | name: FileNameT, 557 | ctx: RequestContext, # noqa: ARG002 558 | ) -> None: 559 | entry = await self.lookup(parent_inode, name) 560 | 561 | if not stat.S_ISDIR(entry.st_mode): 562 | raise FUSEError(errno.ENOTDIR) 563 | 564 | self.remove(parent_inode, entry.st_ino) 565 | 566 | @fail_is_exit 567 | async def access( 568 | self, 569 | inode: InodeT, # noqa: ARG002 570 | mode: ModeT, # noqa: ARG002 571 | ctx: RequestContext, # noqa: ARG002 572 | ) -> bool: 573 | return True 574 | 575 | @fail_is_exit 576 | async def symlink( 577 | self, 578 | parent_inode: InodeT, 579 | name: bytes, 580 | target: bytes, 581 | ctx: RequestContext, 582 | ) -> EntryAttributes: 583 | target_dentry = Dentry.from_row( 584 | self._get_dict_row( 585 | "SELECT * FROM dentrys WHERE name=?", 586 | (target,), 587 | ) 588 | ) 589 | if target_dentry is None: 590 | raise FUSEError(errno.ENOENT) 591 | 592 | with self._db_cursor() as cur: 593 | inode = self._create( 594 | parent_inode=parent_inode, 595 | name=name, 596 | size=4096, 597 | mode=(stat.S_IFLNK | 0o777), 598 | target=target, 599 | db_cursor=cur, 600 | ) 601 | return await self.getattr(inode, ctx) 602 | 603 | @fail_is_exit 604 | async def readlink(self, inode: InodeT, ctx: RequestContext) -> FileNameT: # noqa: ARG002 605 | row = Inode.from_row( 606 | self._get_dict_row("SELECT * FROM inodes WHERE id=?", (inode,)) 607 | ) 608 | if row is None: 609 | raise FUSEError(errno.EINVAL) 610 | if not stat.S_ISLNK(row.mode): 611 | raise FUSEError(errno.EINVAL) 612 | return FileNameT(row.target) 613 | 614 | def xattrs(self, inode: InodeT) -> dict[str, Any]: 615 | return { 616 | "inode": inode, 617 | "nlookup": len(self._nlookup), 618 | "fd_map_inode": len(self._fd_map_inode), 619 | } 620 | 621 | def _to_flat_map(self, data: dict[str, Any]) -> Sequence[XAttrNameT]: 622 | result = [] 623 | 624 | def _set_default(obj: T) -> T | list[Any]: 625 | if isinstance(obj, set): 626 | return list(obj) 627 | return obj 628 | 629 | for key, value in data.items(): 630 | data_json = json.dumps(value, default=_set_default) 631 | result.append(XAttrNameT(f"{key}:{data_json}".encode())) 632 | return result 633 | 634 | @fail_is_exit 635 | async def getxattr( 636 | self, 637 | inode: InodeT, 638 | name: XAttrNameT, 639 | ctx: RequestContext, # noqa: ARG002 640 | ) -> bytes: 641 | value = self.xattrs(inode).get(name.decode(), "") 642 | 643 | result, *unused = self._to_flat_map(value) 644 | return result 645 | 646 | @fail_is_exit 647 | async def listxattr( 648 | self, 649 | inode: InodeT, 650 | ctx: RequestContext, # noqa: ARG002 651 | ) -> Sequence[XAttrNameT]: 652 | return self._to_flat_map(self.xattrs(inode)) 653 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | GNU GENERAL PUBLIC LICENSE 2 | Version 3, 29 June 2007 3 | 4 | Copyright (C) 2007 Free Software Foundation, Inc. 5 | Everyone is permitted to copy and distribute verbatim copies 6 | of this license document, but changing it is not allowed. 7 | 8 | Preamble 9 | 10 | The GNU General Public License is a free, copyleft license for 11 | software and other kinds of works. 12 | 13 | The licenses for most software and other practical works are designed 14 | to take away your freedom to share and change the works. By contrast, 15 | the GNU General Public License is intended to guarantee your freedom to 16 | share and change all versions of a program--to make sure it remains free 17 | software for all its users. We, the Free Software Foundation, use the 18 | GNU General Public License for most of our software; it applies also to 19 | any other work released this way by its authors. You can apply it to 20 | your programs, too. 21 | 22 | When we speak of free software, we are referring to freedom, not 23 | price. Our General Public Licenses are designed to make sure that you 24 | have the freedom to distribute copies of free software (and charge for 25 | them if you wish), that you receive source code or can get it if you 26 | want it, that you can change the software or use pieces of it in new 27 | free programs, and that you know you can do these things. 28 | 29 | To protect your rights, we need to prevent others from denying you 30 | these rights or asking you to surrender the rights. Therefore, you have 31 | certain responsibilities if you distribute copies of the software, or if 32 | you modify it: responsibilities to respect the freedom of others. 33 | 34 | For example, if you distribute copies of such a program, whether 35 | gratis or for a fee, you must pass on to the recipients the same 36 | freedoms that you received. You must make sure that they, too, receive 37 | or can get the source code. And you must show them these terms so they 38 | know their rights. 39 | 40 | Developers that use the GNU GPL protect your rights with two steps: 41 | (1) assert copyright on the software, and (2) offer you this License 42 | giving you legal permission to copy, distribute and/or modify it. 43 | 44 | For the developers' and authors' protection, the GPL clearly explains 45 | that there is no warranty for this free software. For both users' and 46 | authors' sake, the GPL requires that modified versions be marked as 47 | changed, so that their problems will not be attributed erroneously to 48 | authors of previous versions. 49 | 50 | Some devices are designed to deny users access to install or run 51 | modified versions of the software inside them, although the manufacturer 52 | can do so. This is fundamentally incompatible with the aim of 53 | protecting users' freedom to change the software. The systematic 54 | pattern of such abuse occurs in the area of products for individuals to 55 | use, which is precisely where it is most unacceptable. Therefore, we 56 | have designed this version of the GPL to prohibit the practice for those 57 | products. If such problems arise substantially in other domains, we 58 | stand ready to extend this provision to those domains in future versions 59 | of the GPL, as needed to protect the freedom of users. 60 | 61 | Finally, every program is threatened constantly by software patents. 62 | States should not allow patents to restrict development and use of 63 | software on general-purpose computers, but in those that do, we wish to 64 | avoid the special danger that patents applied to a free program could 65 | make it effectively proprietary. To prevent this, the GPL assures that 66 | patents cannot be used to render the program non-free. 67 | 68 | The precise terms and conditions for copying, distribution and 69 | modification follow. 70 | 71 | TERMS AND CONDITIONS 72 | 73 | 0. Definitions. 74 | 75 | "This License" refers to version 3 of the GNU General Public License. 76 | 77 | "Copyright" also means copyright-like laws that apply to other kinds of 78 | works, such as semiconductor masks. 79 | 80 | "The Program" refers to any copyrightable work licensed under this 81 | License. Each licensee is addressed as "you". "Licensees" and 82 | "recipients" may be individuals or organizations. 83 | 84 | To "modify" a work means to copy from or adapt all or part of the work 85 | in a fashion requiring copyright permission, other than the making of an 86 | exact copy. The resulting work is called a "modified version" of the 87 | earlier work or a work "based on" the earlier work. 88 | 89 | A "covered work" means either the unmodified Program or a work based 90 | on the Program. 91 | 92 | To "propagate" a work means to do anything with it that, without 93 | permission, would make you directly or secondarily liable for 94 | infringement under applicable copyright law, except executing it on a 95 | computer or modifying a private copy. Propagation includes copying, 96 | distribution (with or without modification), making available to the 97 | public, and in some countries other activities as well. 98 | 99 | To "convey" a work means any kind of propagation that enables other 100 | parties to make or receive copies. Mere interaction with a user through 101 | a computer network, with no transfer of a copy, is not conveying. 102 | 103 | An interactive user interface displays "Appropriate Legal Notices" 104 | to the extent that it includes a convenient and prominently visible 105 | feature that (1) displays an appropriate copyright notice, and (2) 106 | tells the user that there is no warranty for the work (except to the 107 | extent that warranties are provided), that licensees may convey the 108 | work under this License, and how to view a copy of this License. If 109 | the interface presents a list of user commands or options, such as a 110 | menu, a prominent item in the list meets this criterion. 111 | 112 | 1. Source Code. 113 | 114 | The "source code" for a work means the preferred form of the work 115 | for making modifications to it. "Object code" means any non-source 116 | form of a work. 117 | 118 | A "Standard Interface" means an interface that either is an official 119 | standard defined by a recognized standards body, or, in the case of 120 | interfaces specified for a particular programming language, one that 121 | is widely used among developers working in that language. 122 | 123 | The "System Libraries" of an executable work include anything, other 124 | than the work as a whole, that (a) is included in the normal form of 125 | packaging a Major Component, but which is not part of that Major 126 | Component, and (b) serves only to enable use of the work with that 127 | Major Component, or to implement a Standard Interface for which an 128 | implementation is available to the public in source code form. A 129 | "Major Component", in this context, means a major essential component 130 | (kernel, window system, and so on) of the specific operating system 131 | (if any) on which the executable work runs, or a compiler used to 132 | produce the work, or an object code interpreter used to run it. 133 | 134 | The "Corresponding Source" for a work in object code form means all 135 | the source code needed to generate, install, and (for an executable 136 | work) run the object code and to modify the work, including scripts to 137 | control those activities. However, it does not include the work's 138 | System Libraries, or general-purpose tools or generally available free 139 | programs which are used unmodified in performing those activities but 140 | which are not part of the work. For example, Corresponding Source 141 | includes interface definition files associated with source files for 142 | the work, and the source code for shared libraries and dynamically 143 | linked subprograms that the work is specifically designed to require, 144 | such as by intimate data communication or control flow between those 145 | subprograms and other parts of the work. 146 | 147 | The Corresponding Source need not include anything that users 148 | can regenerate automatically from other parts of the Corresponding 149 | Source. 150 | 151 | The Corresponding Source for a work in source code form is that 152 | same work. 153 | 154 | 2. Basic Permissions. 155 | 156 | All rights granted under this License are granted for the term of 157 | copyright on the Program, and are irrevocable provided the stated 158 | conditions are met. This License explicitly affirms your unlimited 159 | permission to run the unmodified Program. The output from running a 160 | covered work is covered by this License only if the output, given its 161 | content, constitutes a covered work. This License acknowledges your 162 | rights of fair use or other equivalent, as provided by copyright law. 163 | 164 | You may make, run and propagate covered works that you do not 165 | convey, without conditions so long as your license otherwise remains 166 | in force. You may convey covered works to others for the sole purpose 167 | of having them make modifications exclusively for you, or provide you 168 | with facilities for running those works, provided that you comply with 169 | the terms of this License in conveying all material for which you do 170 | not control copyright. Those thus making or running the covered works 171 | for you must do so exclusively on your behalf, under your direction 172 | and control, on terms that prohibit them from making any copies of 173 | your copyrighted material outside their relationship with you. 174 | 175 | Conveying under any other circumstances is permitted solely under 176 | the conditions stated below. Sublicensing is not allowed; section 10 177 | makes it unnecessary. 178 | 179 | 3. Protecting Users' Legal Rights From Anti-Circumvention Law. 180 | 181 | No covered work shall be deemed part of an effective technological 182 | measure under any applicable law fulfilling obligations under article 183 | 11 of the WIPO copyright treaty adopted on 20 December 1996, or 184 | similar laws prohibiting or restricting circumvention of such 185 | measures. 186 | 187 | When you convey a covered work, you waive any legal power to forbid 188 | circumvention of technological measures to the extent such circumvention 189 | is effected by exercising rights under this License with respect to 190 | the covered work, and you disclaim any intention to limit operation or 191 | modification of the work as a means of enforcing, against the work's 192 | users, your or third parties' legal rights to forbid circumvention of 193 | technological measures. 194 | 195 | 4. Conveying Verbatim Copies. 196 | 197 | You may convey verbatim copies of the Program's source code as you 198 | receive it, in any medium, provided that you conspicuously and 199 | appropriately publish on each copy an appropriate copyright notice; 200 | keep intact all notices stating that this License and any 201 | non-permissive terms added in accord with section 7 apply to the code; 202 | keep intact all notices of the absence of any warranty; and give all 203 | recipients a copy of this License along with the Program. 204 | 205 | You may charge any price or no price for each copy that you convey, 206 | and you may offer support or warranty protection for a fee. 207 | 208 | 5. Conveying Modified Source Versions. 209 | 210 | You may convey a work based on the Program, or the modifications to 211 | produce it from the Program, in the form of source code under the 212 | terms of section 4, provided that you also meet all of these conditions: 213 | 214 | a) The work must carry prominent notices stating that you modified 215 | it, and giving a relevant date. 216 | 217 | b) The work must carry prominent notices stating that it is 218 | released under this License and any conditions added under section 219 | 7. This requirement modifies the requirement in section 4 to 220 | "keep intact all notices". 221 | 222 | c) You must license the entire work, as a whole, under this 223 | License to anyone who comes into possession of a copy. This 224 | License will therefore apply, along with any applicable section 7 225 | additional terms, to the whole of the work, and all its parts, 226 | regardless of how they are packaged. This License gives no 227 | permission to license the work in any other way, but it does not 228 | invalidate such permission if you have separately received it. 229 | 230 | d) If the work has interactive user interfaces, each must display 231 | Appropriate Legal Notices; however, if the Program has interactive 232 | interfaces that do not display Appropriate Legal Notices, your 233 | work need not make them do so. 234 | 235 | A compilation of a covered work with other separate and independent 236 | works, which are not by their nature extensions of the covered work, 237 | and which are not combined with it such as to form a larger program, 238 | in or on a volume of a storage or distribution medium, is called an 239 | "aggregate" if the compilation and its resulting copyright are not 240 | used to limit the access or legal rights of the compilation's users 241 | beyond what the individual works permit. Inclusion of a covered work 242 | in an aggregate does not cause this License to apply to the other 243 | parts of the aggregate. 244 | 245 | 6. Conveying Non-Source Forms. 246 | 247 | You may convey a covered work in object code form under the terms 248 | of sections 4 and 5, provided that you also convey the 249 | machine-readable Corresponding Source under the terms of this License, 250 | in one of these ways: 251 | 252 | a) Convey the object code in, or embodied in, a physical product 253 | (including a physical distribution medium), accompanied by the 254 | Corresponding Source fixed on a durable physical medium 255 | customarily used for software interchange. 256 | 257 | b) Convey the object code in, or embodied in, a physical product 258 | (including a physical distribution medium), accompanied by a 259 | written offer, valid for at least three years and valid for as 260 | long as you offer spare parts or customer support for that product 261 | model, to give anyone who possesses the object code either (1) a 262 | copy of the Corresponding Source for all the software in the 263 | product that is covered by this License, on a durable physical 264 | medium customarily used for software interchange, for a price no 265 | more than your reasonable cost of physically performing this 266 | conveying of source, or (2) access to copy the 267 | Corresponding Source from a network server at no charge. 268 | 269 | c) Convey individual copies of the object code with a copy of the 270 | written offer to provide the Corresponding Source. This 271 | alternative is allowed only occasionally and noncommercially, and 272 | only if you received the object code with such an offer, in accord 273 | with subsection 6b. 274 | 275 | d) Convey the object code by offering access from a designated 276 | place (gratis or for a charge), and offer equivalent access to the 277 | Corresponding Source in the same way through the same place at no 278 | further charge. You need not require recipients to copy the 279 | Corresponding Source along with the object code. If the place to 280 | copy the object code is a network server, the Corresponding Source 281 | may be on a different server (operated by you or a third party) 282 | that supports equivalent copying facilities, provided you maintain 283 | clear directions next to the object code saying where to find the 284 | Corresponding Source. Regardless of what server hosts the 285 | Corresponding Source, you remain obligated to ensure that it is 286 | available for as long as needed to satisfy these requirements. 287 | 288 | e) Convey the object code using peer-to-peer transmission, provided 289 | you inform other peers where the object code and Corresponding 290 | Source of the work are being offered to the general public at no 291 | charge under subsection 6d. 292 | 293 | A separable portion of the object code, whose source code is excluded 294 | from the Corresponding Source as a System Library, need not be 295 | included in conveying the object code work. 296 | 297 | A "User Product" is either (1) a "consumer product", which means any 298 | tangible personal property which is normally used for personal, family, 299 | or household purposes, or (2) anything designed or sold for incorporation 300 | into a dwelling. In determining whether a product is a consumer product, 301 | doubtful cases shall be resolved in favor of coverage. For a particular 302 | product received by a particular user, "normally used" refers to a 303 | typical or common use of that class of product, regardless of the status 304 | of the particular user or of the way in which the particular user 305 | actually uses, or expects or is expected to use, the product. A product 306 | is a consumer product regardless of whether the product has substantial 307 | commercial, industrial or non-consumer uses, unless such uses represent 308 | the only significant mode of use of the product. 309 | 310 | "Installation Information" for a User Product means any methods, 311 | procedures, authorization keys, or other information required to install 312 | and execute modified versions of a covered work in that User Product from 313 | a modified version of its Corresponding Source. The information must 314 | suffice to ensure that the continued functioning of the modified object 315 | code is in no case prevented or interfered with solely because 316 | modification has been made. 317 | 318 | If you convey an object code work under this section in, or with, or 319 | specifically for use in, a User Product, and the conveying occurs as 320 | part of a transaction in which the right of possession and use of the 321 | User Product is transferred to the recipient in perpetuity or for a 322 | fixed term (regardless of how the transaction is characterized), the 323 | Corresponding Source conveyed under this section must be accompanied 324 | by the Installation Information. But this requirement does not apply 325 | if neither you nor any third party retains the ability to install 326 | modified object code on the User Product (for example, the work has 327 | been installed in ROM). 328 | 329 | The requirement to provide Installation Information does not include a 330 | requirement to continue to provide support service, warranty, or updates 331 | for a work that has been modified or installed by the recipient, or for 332 | the User Product in which it has been modified or installed. Access to a 333 | network may be denied when the modification itself materially and 334 | adversely affects the operation of the network or violates the rules and 335 | protocols for communication across the network. 336 | 337 | Corresponding Source conveyed, and Installation Information provided, 338 | in accord with this section must be in a format that is publicly 339 | documented (and with an implementation available to the public in 340 | source code form), and must require no special password or key for 341 | unpacking, reading or copying. 342 | 343 | 7. Additional Terms. 344 | 345 | "Additional permissions" are terms that supplement the terms of this 346 | License by making exceptions from one or more of its conditions. 347 | Additional permissions that are applicable to the entire Program shall 348 | be treated as though they were included in this License, to the extent 349 | that they are valid under applicable law. If additional permissions 350 | apply only to part of the Program, that part may be used separately 351 | under those permissions, but the entire Program remains governed by 352 | this License without regard to the additional permissions. 353 | 354 | When you convey a copy of a covered work, you may at your option 355 | remove any additional permissions from that copy, or from any part of 356 | it. (Additional permissions may be written to require their own 357 | removal in certain cases when you modify the work.) You may place 358 | additional permissions on material, added by you to a covered work, 359 | for which you have or can give appropriate copyright permission. 360 | 361 | Notwithstanding any other provision of this License, for material you 362 | add to a covered work, you may (if authorized by the copyright holders of 363 | that material) supplement the terms of this License with terms: 364 | 365 | a) Disclaiming warranty or limiting liability differently from the 366 | terms of sections 15 and 16 of this License; or 367 | 368 | b) Requiring preservation of specified reasonable legal notices or 369 | author attributions in that material or in the Appropriate Legal 370 | Notices displayed by works containing it; or 371 | 372 | c) Prohibiting misrepresentation of the origin of that material, or 373 | requiring that modified versions of such material be marked in 374 | reasonable ways as different from the original version; or 375 | 376 | d) Limiting the use for publicity purposes of names of licensors or 377 | authors of the material; or 378 | 379 | e) Declining to grant rights under trademark law for use of some 380 | trade names, trademarks, or service marks; or 381 | 382 | f) Requiring indemnification of licensors and authors of that 383 | material by anyone who conveys the material (or modified versions of 384 | it) with contractual assumptions of liability to the recipient, for 385 | any liability that these contractual assumptions directly impose on 386 | those licensors and authors. 387 | 388 | All other non-permissive additional terms are considered "further 389 | restrictions" within the meaning of section 10. If the Program as you 390 | received it, or any part of it, contains a notice stating that it is 391 | governed by this License along with a term that is a further 392 | restriction, you may remove that term. If a license document contains 393 | a further restriction but permits relicensing or conveying under this 394 | License, you may add to a covered work material governed by the terms 395 | of that license document, provided that the further restriction does 396 | not survive such relicensing or conveying. 397 | 398 | If you add terms to a covered work in accord with this section, you 399 | must place, in the relevant source files, a statement of the 400 | additional terms that apply to those files, or a notice indicating 401 | where to find the applicable terms. 402 | 403 | Additional terms, permissive or non-permissive, may be stated in the 404 | form of a separately written license, or stated as exceptions; 405 | the above requirements apply either way. 406 | 407 | 8. Termination. 408 | 409 | You may not propagate or modify a covered work except as expressly 410 | provided under this License. Any attempt otherwise to propagate or 411 | modify it is void, and will automatically terminate your rights under 412 | this License (including any patent licenses granted under the third 413 | paragraph of section 11). 414 | 415 | However, if you cease all violation of this License, then your 416 | license from a particular copyright holder is reinstated (a) 417 | provisionally, unless and until the copyright holder explicitly and 418 | finally terminates your license, and (b) permanently, if the copyright 419 | holder fails to notify you of the violation by some reasonable means 420 | prior to 60 days after the cessation. 421 | 422 | Moreover, your license from a particular copyright holder is 423 | reinstated permanently if the copyright holder notifies you of the 424 | violation by some reasonable means, this is the first time you have 425 | received notice of violation of this License (for any work) from that 426 | copyright holder, and you cure the violation prior to 30 days after 427 | your receipt of the notice. 428 | 429 | Termination of your rights under this section does not terminate the 430 | licenses of parties who have received copies or rights from you under 431 | this License. If your rights have been terminated and not permanently 432 | reinstated, you do not qualify to receive new licenses for the same 433 | material under section 10. 434 | 435 | 9. Acceptance Not Required for Having Copies. 436 | 437 | You are not required to accept this License in order to receive or 438 | run a copy of the Program. Ancillary propagation of a covered work 439 | occurring solely as a consequence of using peer-to-peer transmission 440 | to receive a copy likewise does not require acceptance. However, 441 | nothing other than this License grants you permission to propagate or 442 | modify any covered work. These actions infringe copyright if you do 443 | not accept this License. Therefore, by modifying or propagating a 444 | covered work, you indicate your acceptance of this License to do so. 445 | 446 | 10. Automatic Licensing of Downstream Recipients. 447 | 448 | Each time you convey a covered work, the recipient automatically 449 | receives a license from the original licensors, to run, modify and 450 | propagate that work, subject to this License. You are not responsible 451 | for enforcing compliance by third parties with this License. 452 | 453 | An "entity transaction" is a transaction transferring control of an 454 | organization, or substantially all assets of one, or subdividing an 455 | organization, or merging organizations. If propagation of a covered 456 | work results from an entity transaction, each party to that 457 | transaction who receives a copy of the work also receives whatever 458 | licenses to the work the party's predecessor in interest had or could 459 | give under the previous paragraph, plus a right to possession of the 460 | Corresponding Source of the work from the predecessor in interest, if 461 | the predecessor has it or can get it with reasonable efforts. 462 | 463 | You may not impose any further restrictions on the exercise of the 464 | rights granted or affirmed under this License. For example, you may 465 | not impose a license fee, royalty, or other charge for exercise of 466 | rights granted under this License, and you may not initiate litigation 467 | (including a cross-claim or counterclaim in a lawsuit) alleging that 468 | any patent claim is infringed by making, using, selling, offering for 469 | sale, or importing the Program or any portion of it. 470 | 471 | 11. Patents. 472 | 473 | A "contributor" is a copyright holder who authorizes use under this 474 | License of the Program or a work on which the Program is based. The 475 | work thus licensed is called the contributor's "contributor version". 476 | 477 | A contributor's "essential patent claims" are all patent claims 478 | owned or controlled by the contributor, whether already acquired or 479 | hereafter acquired, that would be infringed by some manner, permitted 480 | by this License, of making, using, or selling its contributor version, 481 | but do not include claims that would be infringed only as a 482 | consequence of further modification of the contributor version. For 483 | purposes of this definition, "control" includes the right to grant 484 | patent sublicenses in a manner consistent with the requirements of 485 | this License. 486 | 487 | Each contributor grants you a non-exclusive, worldwide, royalty-free 488 | patent license under the contributor's essential patent claims, to 489 | make, use, sell, offer for sale, import and otherwise run, modify and 490 | propagate the contents of its contributor version. 491 | 492 | In the following three paragraphs, a "patent license" is any express 493 | agreement or commitment, however denominated, not to enforce a patent 494 | (such as an express permission to practice a patent or covenant not to 495 | sue for patent infringement). To "grant" such a patent license to a 496 | party means to make such an agreement or commitment not to enforce a 497 | patent against the party. 498 | 499 | If you convey a covered work, knowingly relying on a patent license, 500 | and the Corresponding Source of the work is not available for anyone 501 | to copy, free of charge and under the terms of this License, through a 502 | publicly available network server or other readily accessible means, 503 | then you must either (1) cause the Corresponding Source to be so 504 | available, or (2) arrange to deprive yourself of the benefit of the 505 | patent license for this particular work, or (3) arrange, in a manner 506 | consistent with the requirements of this License, to extend the patent 507 | license to downstream recipients. "Knowingly relying" means you have 508 | actual knowledge that, but for the patent license, your conveying the 509 | covered work in a country, or your recipient's use of the covered work 510 | in a country, would infringe one or more identifiable patents in that 511 | country that you have reason to believe are valid. 512 | 513 | If, pursuant to or in connection with a single transaction or 514 | arrangement, you convey, or propagate by procuring conveyance of, a 515 | covered work, and grant a patent license to some of the parties 516 | receiving the covered work authorizing them to use, propagate, modify 517 | or convey a specific copy of the covered work, then the patent license 518 | you grant is automatically extended to all recipients of the covered 519 | work and works based on it. 520 | 521 | A patent license is "discriminatory" if it does not include within 522 | the scope of its coverage, prohibits the exercise of, or is 523 | conditioned on the non-exercise of one or more of the rights that are 524 | specifically granted under this License. You may not convey a covered 525 | work if you are a party to an arrangement with a third party that is 526 | in the business of distributing software, under which you make payment 527 | to the third party based on the extent of your activity of conveying 528 | the work, and under which the third party grants, to any of the 529 | parties who would receive the covered work from you, a discriminatory 530 | patent license (a) in connection with copies of the covered work 531 | conveyed by you (or copies made from those copies), or (b) primarily 532 | for and in connection with specific products or compilations that 533 | contain the covered work, unless you entered into that arrangement, 534 | or that patent license was granted, prior to 28 March 2007. 535 | 536 | Nothing in this License shall be construed as excluding or limiting 537 | any implied license or other defenses to infringement that may 538 | otherwise be available to you under applicable patent law. 539 | 540 | 12. No Surrender of Others' Freedom. 541 | 542 | If conditions are imposed on you (whether by court order, agreement or 543 | otherwise) that contradict the conditions of this License, they do not 544 | excuse you from the conditions of this License. If you cannot convey a 545 | covered work so as to satisfy simultaneously your obligations under this 546 | License and any other pertinent obligations, then as a consequence you may 547 | not convey it at all. For example, if you agree to terms that obligate you 548 | to collect a royalty for further conveying from those to whom you convey 549 | the Program, the only way you could satisfy both those terms and this 550 | License would be to refrain entirely from conveying the Program. 551 | 552 | 13. Use with the GNU Affero General Public License. 553 | 554 | Notwithstanding any other provision of this License, you have 555 | permission to link or combine any covered work with a work licensed 556 | under version 3 of the GNU Affero General Public License into a single 557 | combined work, and to convey the resulting work. The terms of this 558 | License will continue to apply to the part which is the covered work, 559 | but the special requirements of the GNU Affero General Public License, 560 | section 13, concerning interaction through a network will apply to the 561 | combination as such. 562 | 563 | 14. Revised Versions of this License. 564 | 565 | The Free Software Foundation may publish revised and/or new versions of 566 | the GNU General Public License from time to time. Such new versions will 567 | be similar in spirit to the present version, but may differ in detail to 568 | address new problems or concerns. 569 | 570 | Each version is given a distinguishing version number. If the 571 | Program specifies that a certain numbered version of the GNU General 572 | Public License "or any later version" applies to it, you have the 573 | option of following the terms and conditions either of that numbered 574 | version or of any later version published by the Free Software 575 | Foundation. If the Program does not specify a version number of the 576 | GNU General Public License, you may choose any version ever published 577 | by the Free Software Foundation. 578 | 579 | If the Program specifies that a proxy can decide which future 580 | versions of the GNU General Public License can be used, that proxy's 581 | public statement of acceptance of a version permanently authorizes you 582 | to choose that version for the Program. 583 | 584 | Later license versions may give you additional or different 585 | permissions. However, no additional obligations are imposed on any 586 | author or copyright holder as a result of your choosing to follow a 587 | later version. 588 | 589 | 15. Disclaimer of Warranty. 590 | 591 | THERE IS NO WARRANTY FOR THE PROGRAM, TO THE EXTENT PERMITTED BY 592 | APPLICABLE LAW. EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT 593 | HOLDERS AND/OR OTHER PARTIES PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY 594 | OF ANY KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO, 595 | THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR 596 | PURPOSE. THE ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE PROGRAM 597 | IS WITH YOU. SHOULD THE PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF 598 | ALL NECESSARY SERVICING, REPAIR OR CORRECTION. 599 | 600 | 16. Limitation of Liability. 601 | 602 | IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING 603 | WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MODIFIES AND/OR CONVEYS 604 | THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, INCLUDING ANY 605 | GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING OUT OF THE 606 | USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED TO LOSS OF 607 | DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD 608 | PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER PROGRAMS), 609 | EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF 610 | SUCH DAMAGES. 611 | 612 | 17. Interpretation of Sections 15 and 16. 613 | 614 | If the disclaimer of warranty and limitation of liability provided 615 | above cannot be given local legal effect according to their terms, 616 | reviewing courts shall apply local law that most closely approximates 617 | an absolute waiver of all civil liability in connection with the 618 | Program, unless a warranty or assumption of liability accompanies a 619 | copy of the Program in return for a fee. 620 | 621 | END OF TERMS AND CONDITIONS 622 | 623 | How to Apply These Terms to Your New Programs 624 | 625 | If you develop a new program, and you want it to be of the greatest 626 | possible use to the public, the best way to achieve this is to make it 627 | free software which everyone can redistribute and change under these terms. 628 | 629 | To do so, attach the following notices to the program. It is safest 630 | to attach them to the start of each source file to most effectively 631 | state the exclusion of warranty; and each file should have at least 632 | the "copyright" line and a pointer to where the full notice is found. 633 | 634 | 635 | Copyright (C) 636 | 637 | This program is free software: you can redistribute it and/or modify 638 | it under the terms of the GNU General Public License as published by 639 | the Free Software Foundation, either version 3 of the License, or 640 | (at your option) any later version. 641 | 642 | This program is distributed in the hope that it will be useful, 643 | but WITHOUT ANY WARRANTY; without even the implied warranty of 644 | MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 645 | GNU General Public License for more details. 646 | 647 | You should have received a copy of the GNU General Public License 648 | along with this program. If not, see . 649 | 650 | Also add information on how to contact you by electronic and paper mail. 651 | 652 | If the program does terminal interaction, make it output a short 653 | notice like this when it starts in an interactive mode: 654 | 655 | Copyright (C) 656 | This program comes with ABSOLUTELY NO WARRANTY; for details type `show w'. 657 | This is free software, and you are welcome to redistribute it 658 | under certain conditions; type `show c' for details. 659 | 660 | The hypothetical commands `show w' and `show c' should show the appropriate 661 | parts of the General Public License. Of course, your program's commands 662 | might be different; for a GUI interface, you would use an "about box". 663 | 664 | You should also get your employer (if you work as a programmer) or school, 665 | if any, to sign a "copyright disclaimer" for the program, if necessary. 666 | For more information on this, and how to apply and follow the GNU GPL, see 667 | . 668 | 669 | The GNU General Public License does not permit incorporating your program 670 | into proprietary programs. If your program is a subroutine library, you 671 | may consider it more useful to permit linking proprietary applications with 672 | the library. If this is what you want to do, use the GNU Lesser General 673 | Public License instead of this License. But first, please read 674 | . 675 | -------------------------------------------------------------------------------- /yandex_fuse/ya_music_fs.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | import errno 4 | import logging 5 | import os 6 | import stat 7 | import time 8 | from asyncio import ( 9 | FIRST_COMPLETED, 10 | CancelledError, 11 | Event, 12 | InvalidStateError, 13 | Task, 14 | create_task, 15 | sleep, 16 | wait, 17 | wait_for, 18 | ) 19 | from asyncio import ( 20 | TimeoutError as AsyncTimeoutError, 21 | ) 22 | from contextlib import contextmanager, suppress 23 | from dataclasses import dataclass 24 | from io import BytesIO 25 | from pathlib import Path 26 | from typing import TYPE_CHECKING, Any, ParamSpec, TypeVar 27 | 28 | from aiohttp import ( 29 | ClientError, 30 | ClientSession, 31 | ClientTimeout, 32 | ConnectionTimeoutError, 33 | SocketTimeoutError, 34 | ) 35 | from pyfuse3 import ( 36 | FileHandleT, 37 | FileInfo, 38 | FlagT, 39 | FUSEError, 40 | InodeT, 41 | RequestContext, 42 | XAttrNameT, 43 | ) 44 | from yandex_music.exceptions import ( # type: ignore[import-untyped] 45 | BadRequestError, 46 | NetworkError, 47 | NotFoundError, 48 | TimedOutError, 49 | UnauthorizedError, 50 | ) 51 | 52 | from yandex_fuse.virt_fs import SQLRow, VirtFS, fail_is_exit 53 | from yandex_fuse.ya_player import ExtendTrack, TrackTag, YandexMusicPlayer 54 | 55 | if TYPE_CHECKING: 56 | from collections.abc import ( 57 | Callable, 58 | Coroutine, 59 | Iterator, 60 | ) 61 | from sqlite3 import Cursor 62 | 63 | log = logging.getLogger(__name__) 64 | 65 | LIMIT_TASKS = 10 66 | LIMIT_ONYOURWAVE = 150 67 | 68 | PLAYLIST_ID2NAME = {"likes": "Мне нравится", "user:onyourwave": "Моя волна"} 69 | 70 | 71 | P = ParamSpec("P") 72 | T = TypeVar("T") 73 | 74 | 75 | def retry_request( 76 | func: Callable[..., Coroutine[Any, Any, T]], count: int = 3 77 | ) -> Callable[..., Coroutine[Any, Any, T | None]]: 78 | async def wrapper(*args: P.args, **kwargs: P.kwargs) -> T | None: 79 | for retry in range(1, count + 1): 80 | try: 81 | return await func(*args, **kwargs) 82 | except (BadRequestError, NotFoundError): 83 | raise 84 | except ( 85 | NetworkError, 86 | TimedOutError, 87 | UnauthorizedError, 88 | ClientError, 89 | SocketTimeoutError, 90 | ) as err: 91 | log.error("Error request: %r %r/%r", err, args[1:], kwargs) # noqa:TRY400 92 | await sleep(0.75 * retry) 93 | log.debug("Retry %d/%d - %r", retry + 1, count, func) 94 | raise RuntimeError("Retry limit exceeded.") 95 | 96 | return wrapper 97 | 98 | 99 | class Buffer: 100 | CHUNK_SIZE = 128 101 | 102 | def __init__( 103 | self, 104 | direct_link: str, 105 | client_session: ClientSession, 106 | track: SQLTrack, 107 | fuse_music: YaMusicFS, 108 | ) -> None: 109 | self.__direct_link = direct_link 110 | self.__client_session = client_session 111 | self.__bytes = BytesIO() 112 | self.__data = bytearray() 113 | self.__track = track 114 | self.__total_read = 0 115 | self.__tag = TrackTag.from_json(track.__dict__) 116 | self.__fuse_music = fuse_music 117 | self.__download_task: Task[Any] | None = create_task( 118 | self.download(), name="download" 119 | ) 120 | self.__tagging = False 121 | self.__ready_read = Event() 122 | 123 | def __del__(self) -> None: 124 | self.__ready_read.set() 125 | if self.__download_task is not None: 126 | self.__download_task.cancel() 127 | 128 | @property 129 | def is_downloded(self) -> bool: 130 | return self.__download_task is None 131 | 132 | def write(self, buffer: bytes) -> int: 133 | self.__ready_read.set() 134 | return self.__bytes.write(buffer) 135 | 136 | @property 137 | def offset(self) -> int: 138 | if not self.__tagging: 139 | return 0 140 | return len(self.__bytes.getbuffer()) 141 | 142 | async def read_from(self, offset: int, size: int) -> bytes: 143 | if self.offset < offset + size: 144 | while (offset + size - self.offset) > 0: 145 | if self.is_downloded: 146 | break 147 | with suppress(InvalidStateError): 148 | if self.__download_task is not None: 149 | self.__download_task.result() 150 | 151 | self.__ready_read.clear() 152 | try: 153 | await wait_for(self.__ready_read.wait(), timeout=5) 154 | except AsyncTimeoutError: 155 | log.warning("Slow downloading %s", self.__track.name) 156 | 157 | self.__total_read += size 158 | return bytes(self.__bytes.getbuffer()[offset : offset + size]) 159 | 160 | def total_second(self) -> int: 161 | s2p = self.__tag.duration_ms / self.__track.size 162 | second_read = s2p * self.__total_read 163 | return int(min(second_read // 1000, self.__tag.duration_ms // 1000)) 164 | 165 | def _write_tag(self, new_buffer: bytes, real_size: int) -> None: 166 | self.__bytes.seek(0) 167 | self.write(new_buffer) 168 | self.__tagging = True 169 | 170 | if real_size + self.__tag.size != self.__track.size: 171 | log.warning( 172 | "Missmath tagging. " 173 | "Track size: %d, tag size: %d " 174 | "Content-Length: %d", 175 | self.__track.size, 176 | self.__tag.size, 177 | real_size, 178 | ) 179 | 180 | @retry_request 181 | async def download(self) -> None: 182 | try: 183 | buffer = BytesIO() 184 | async with self.__client_session.request( 185 | "GET", 186 | self.__direct_link, 187 | headers={"range": f"bytes={self.offset}-"}, 188 | ) as resp: 189 | resp.raise_for_status() 190 | async for chunk in resp.content.iter_chunked(self.CHUNK_SIZE): 191 | if self.__tagging: 192 | self.write(chunk) 193 | continue 194 | 195 | buffer.write(chunk) 196 | new_buffer = self.__tag.to_bytes(buffer, self.__track.codec) 197 | if new_buffer is None: 198 | continue 199 | self._write_tag( 200 | new_buffer, int(resp.headers["Content-Length"]) 201 | ) 202 | 203 | except CancelledError: 204 | raise 205 | except ConnectionTimeoutError: 206 | direct_link = await self.__fuse_music.get_or_update_direct_link( 207 | self.__track.track_id, 208 | self.__track.codec, 209 | self.__track.bitrate, 210 | ) 211 | if direct_link is None: 212 | raise RuntimeError("Error get direct link.") from None 213 | self.__direct_link = direct_link 214 | raise 215 | except Exception: 216 | log.exception("Error downloading ..") 217 | raise 218 | else: 219 | log.debug("Track %s downloaded", self.__track.name) 220 | self.__ready_read.set() 221 | if self.__download_task is None: 222 | return 223 | download_task = self.__download_task 224 | self.__download_task = None 225 | download_task.cancel() 226 | 227 | 228 | @dataclass 229 | class SQLTrack(SQLRow): 230 | __tablename__ = "tracks" 231 | 232 | name: bytes 233 | track_id: str 234 | playlist_id: str 235 | codec: str 236 | bitrate: int 237 | quality: str 238 | size: int 239 | artist: str 240 | title: str 241 | album: str 242 | year: str 243 | genre: str 244 | duration_ms: int 245 | 246 | inode: InodeT 247 | id: int | None = None 248 | 249 | 250 | @dataclass 251 | class SQLPlaylist(SQLRow): 252 | __tablename__ = "playlists" 253 | 254 | name: str 255 | playlist_id: str 256 | inode: InodeT 257 | revision: int = 0 258 | batch_id: str = "" 259 | station_id: str = "" 260 | id: int | None = None 261 | 262 | 263 | @dataclass 264 | class SQLDirectLink(SQLRow): 265 | __tablename__ = "direct_link" 266 | 267 | track_id: str 268 | link: str 269 | expired: int 270 | id: int | None = None 271 | 272 | 273 | @dataclass 274 | class StreamReader: 275 | buffer: Buffer 276 | track: SQLTrack 277 | is_send_feedback: bool = False 278 | 279 | 280 | class YaMusicFS(VirtFS): 281 | FILE_DB = str(Path.home().joinpath(".cache/yandex-fuse2.db")) 282 | 283 | CREATE_TABLE_QUERYS = ( 284 | *VirtFS.CREATE_TABLE_QUERYS, 285 | """ 286 | CREATE TABLE IF NOT EXISTS playlists ( 287 | id INTEGER PRIMARY KEY AUTOINCREMENT, 288 | inode INT NOT NULL 289 | REFERENCES inodes(id) ON DELETE CASCADE, 290 | playlist_id TEXT(255) NOT NULL UNIQUE, 291 | station_id TEXT(255), 292 | batch_id TEXT(255), 293 | revision INT DEFAULT 0, 294 | name TEXT(255) NOT NULL, 295 | UNIQUE (name, playlist_id, inode) 296 | ) 297 | """, 298 | """ 299 | CREATE TABLE IF NOT EXISTS tracks ( 300 | id INTEGER PRIMARY KEY AUTOINCREMENT, 301 | inode INT NOT NULL 302 | REFERENCES inodes(id) ON DELETE CASCADE, 303 | name BLOB(256) NOT NULL, 304 | track_id TEXT(255) NOT NULL UNIQUE, 305 | playlist_id TEXT(255) NOT NULL 306 | REFERENCES playlists(playlist_id) ON DELETE RESTRICT, 307 | codec BLOB(8) NOT NULL, 308 | bitrate INT NOT NULL, 309 | quality TEXT(20) NOT NULL, 310 | size INT NOT NULL, 311 | artist TEXT(255), 312 | title TEXT(255), 313 | album TEXT(255), 314 | year TEXT(255), 315 | genre TEXT(255), 316 | duration_ms INT, 317 | UNIQUE (name, track_id, inode) 318 | ) 319 | """, 320 | """ 321 | CREATE TABLE IF NOT EXISTS direct_link ( 322 | id INTEGER PRIMARY KEY AUTOINCREMENT, 323 | track_id TEXT(255) NOT NULL UNIQUE, 324 | link BLOB(256) NOT NULL, 325 | expired INT NOT NULL 326 | ) 327 | """, 328 | ) 329 | 330 | CHUNK_SIZE = 128 331 | 332 | def __init__(self, *args: P.args, **kwargs: P.kwargs) -> None: 333 | super().__init__(*args, **kwargs) 334 | self.__client_session: ClientSession | None = None 335 | self.__ya_player: YandexMusicPlayer | None = None 336 | self._fd_map_stream: dict[int, StreamReader] = {} 337 | 338 | async def start(self) -> None: 339 | self.__client_session = ClientSession( 340 | raise_for_status=False, 341 | timeout=ClientTimeout(sock_read=3, sock_connect=5), 342 | ) 343 | self.__ya_player = YandexMusicPlayer( 344 | Path.home().joinpath(".config/yandex-fuse.json"), 345 | self.__client_session, 346 | ) 347 | self.__task = create_task(self.__fsm(), name="fsm") 348 | 349 | @property 350 | def _client_session(self) -> ClientSession: 351 | if self.__client_session is None: 352 | raise RuntimeError("ClientSession is not init!") 353 | return self.__client_session 354 | 355 | @property 356 | def _ya_player(self) -> YandexMusicPlayer: 357 | if self.__ya_player is None: 358 | raise RuntimeError( 359 | "YandexMusicPlayer is not init! %r", self.__ya_player 360 | ) 361 | return self.__ya_player 362 | 363 | @contextmanager 364 | def _get_direct_link( 365 | self, track_id: str 366 | ) -> Iterator[tuple[str | None, Cursor]]: 367 | with self._db_cursor() as cur: 368 | cur.execute( 369 | "SELECT * FROM direct_link WHERE track_id=?", 370 | (track_id,), 371 | ) 372 | row = cur.fetchone() 373 | 374 | if row is None: 375 | yield (None, cur) 376 | return 377 | expired = int(time.time() * 1e9) 378 | if row["expired"] > expired: 379 | log.debug( 380 | "Track %s, lifetime %r", 381 | track_id, 382 | (row["expired"] - expired) / 1e9, 383 | ) 384 | yield (row["link"], cur) 385 | return 386 | log.debug("Direct link %s direct link has expired", track_id) 387 | yield (None, cur) 388 | 389 | @retry_request 390 | async def get_or_update_direct_link( 391 | self, 392 | track_id: str, 393 | codec: str, 394 | bitrate_in_kbps: int, 395 | ) -> str | None: 396 | with self._get_direct_link(track_id) as (direct_link, cursor): 397 | if direct_link is not None: 398 | try: 399 | async with self._client_session.request( 400 | "HEAD", 401 | direct_link, 402 | ) as resp: 403 | if resp.ok: 404 | return direct_link 405 | except ClientError as err: 406 | log.error("Fail get direct link: %r", err) # noqa: TRY400 407 | 408 | new_direct_links = await self._ya_player.get_download_links( 409 | track_id, 410 | codec, 411 | bitrate_in_kbps, 412 | ) 413 | 414 | if new_direct_links is None: 415 | return None 416 | 417 | for new_direct_link in new_direct_links: 418 | log.debug("Check direct link %s", new_direct_link) 419 | try: 420 | async with self._client_session.request( 421 | "HEAD", 422 | new_direct_link, 423 | ) as resp: 424 | if not resp.ok: 425 | continue 426 | except ClientError as err: 427 | log.error("Fail get direct link: %r", err) # noqa: TRY400 428 | continue 429 | 430 | expired = int((time.time() + 8600) * 1e9) 431 | cursor.execute( 432 | """ 433 | INSERT INTO direct_link 434 | (track_id, link, expired) 435 | VALUES(?, ?, ?) 436 | ON CONFLICT(track_id) 437 | DO UPDATE SET link=excluded.link,expired=excluded.expired 438 | """, 439 | (track_id, new_direct_link, expired), 440 | ) 441 | log.debug( 442 | "Direct link: %s, track: %s", new_direct_link, track_id 443 | ) 444 | return new_direct_link 445 | return None 446 | 447 | def _get_playlist_by_id(self, playlist_id: str) -> SQLPlaylist | None: 448 | return SQLPlaylist.from_row( 449 | self._get_dict_row( 450 | """ 451 | SELECT 452 | * 453 | FROM playlists 454 | WHERE 455 | playlists.playlist_id = ? 456 | """, 457 | (playlist_id,), 458 | ) 459 | ) 460 | 461 | def _create_plyalist(self, playlist: SQLPlaylist) -> SQLPlaylist: 462 | with self._db_cursor() as cur: 463 | inode = self._create( 464 | parent_inode=self.ROOT_INODE, 465 | name=playlist.name.encode(), 466 | size=0, 467 | mode=(stat.S_IFDIR | 0o755), 468 | target=b"", 469 | db_cursor=cur, 470 | ) 471 | playlist.inode = inode 472 | 473 | cur.execute(*playlist.insert()) 474 | 475 | return playlist 476 | 477 | def _update_plyalist( 478 | self, 479 | uid: str, 480 | revision: int, 481 | batch_id: str | None = None, 482 | ) -> None: 483 | with self._db_cursor() as cur: 484 | cur.execute( 485 | """ 486 | UPDATE playlists SET revision=?,batch_id=? WHERE playlist_id=?; 487 | """, 488 | ( 489 | revision, 490 | batch_id, 491 | uid, 492 | ), 493 | ) 494 | 495 | def _get_track_by_id(self, track_id: str) -> SQLTrack | None: 496 | result = self._get_dict_row( 497 | """ 498 | SELECT 499 | * 500 | FROM tracks 501 | WHERE track_id=? 502 | """, 503 | (track_id,), 504 | ) 505 | if result is None: 506 | return None 507 | return SQLTrack(**result) 508 | 509 | def _get_track_by_inode(self, inode: int) -> SQLTrack | None: 510 | return SQLTrack.from_row( 511 | self._get_dict_row( 512 | """ 513 | SELECT 514 | * 515 | FROM tracks 516 | WHERE inode=? 517 | """, 518 | (inode,), 519 | ) 520 | ) 521 | 522 | def _create_track(self, track: SQLTrack, parent_inode: int) -> None: 523 | with self._db_cursor() as cur: 524 | inode = self._create( 525 | parent_inode=InodeT(parent_inode), 526 | name=track.name, 527 | size=track.size, 528 | mode=(stat.S_IFREG | 0o644), 529 | target=b"", 530 | db_cursor=cur, 531 | ) 532 | log.debug( 533 | "Create track %s, inode: %d, parent inode: %d", 534 | track.name, 535 | inode, 536 | parent_inode, 537 | ) 538 | track.inode = inode 539 | cur.execute(*track.insert()) 540 | 541 | def _symlink_track( 542 | self, exist_track: SQLTrack, track: ExtendTrack, dir_inode: InodeT 543 | ) -> None: 544 | playlist_info = self._get_playlist_by_id(exist_track.playlist_id) 545 | if playlist_info is None: 546 | raise RuntimeError("Error get playlist info") 547 | 548 | target = str( 549 | Path("..") 550 | .joinpath(playlist_info.name) 551 | .joinpath(exist_track.name.decode()) 552 | ).encode() 553 | 554 | track_link = self._get_inode_by_name( 555 | InodeT(dir_inode), track.save_name.encode() 556 | ) 557 | if track_link is not None: 558 | return 559 | 560 | with self._db_cursor() as cur: 561 | inode = self._create( 562 | parent_inode=dir_inode, 563 | name=track.save_name.encode(), 564 | size=4096, 565 | mode=(stat.S_IFLNK | 0o777), 566 | target=target, 567 | db_cursor=cur, 568 | ) 569 | log.debug( 570 | "Symlink track %s -> %d / %s - %d", 571 | track.track_id, 572 | inode, 573 | track.save_name, 574 | dir_inode, 575 | ) 576 | 577 | async def _update_track( 578 | self, 579 | track: ExtendTrack, 580 | playlist_id: str, 581 | dir_inode: InodeT, 582 | *, 583 | uniq: bool = False, 584 | ) -> None: 585 | if ( 586 | exist_track := self._get_track_by_id(track.track_id) 587 | ) is not None and exist_track.playlist_id != playlist_id: 588 | if uniq: 589 | return 590 | self._symlink_track(exist_track, track, dir_inode) 591 | return 592 | 593 | if ( 594 | direct_link := await self.get_or_update_direct_link( 595 | track.track_id, 596 | track.codec, 597 | track.bitrate_in_kbps, 598 | ) 599 | ) is None: 600 | log.warning("Track %s is not be downloaded!", track.save_name) 601 | return 602 | 603 | async with self._client_session.request( 604 | "GET", 605 | direct_link, 606 | ) as resp: 607 | track.size = int(resp.headers["Content-Length"]) 608 | 609 | byte = BytesIO() 610 | async for chunk in resp.content.iter_chunked(1024): 611 | byte.write(chunk) 612 | new_buffer = track.tag.to_bytes(byte, track.codec) 613 | if new_buffer is None: 614 | continue 615 | track.size += track.tag.size 616 | break 617 | 618 | if track.save_name.endswith("unknown"): 619 | log.warning("Track %s create skip.", track.save_name) 620 | return 621 | 622 | if ( 623 | inode_by_name := self._get_inode_by_name( 624 | dir_inode, track.save_name.encode() 625 | ) 626 | ) is not None: 627 | exist_track = self._get_track_by_inode(inode_by_name) 628 | if exist_track is None: 629 | raise RuntimeError( 630 | f""" 631 | Missing track {track.save_name} / {track.track_id} 632 | by inode {inode_by_name}""" 633 | ) 634 | log.debug( 635 | "Update track %s, track id %s -> %s, inode: %d", 636 | track.save_name, 637 | exist_track.track_id, 638 | track.track_id, 639 | inode_by_name, 640 | ) 641 | with self._db_cursor() as cur: 642 | cur.execute( 643 | """ 644 | UPDATE tracks SET track_id=?,size=? WHERE id=?; 645 | """, 646 | (track.track_id, track.size, exist_track.id), 647 | ) 648 | cur.execute( 649 | """ 650 | UPDATE inodes SET size=? WHERE id=?; 651 | """, 652 | (track.size, exist_track.inode), 653 | ) 654 | return 655 | 656 | log.debug( 657 | "Create track %s / %s - %d", 658 | track.track_id, 659 | track.save_name, 660 | dir_inode, 661 | ) 662 | 663 | tag = track.tag.to_dict() 664 | self._create_track( 665 | SQLTrack( 666 | name=track.save_name.encode(), 667 | track_id=track.track_id, 668 | playlist_id=playlist_id, 669 | codec=track.codec, 670 | bitrate=track.bitrate_in_kbps, 671 | quality=track.quality, 672 | size=track.size, 673 | artist=tag["artist"], 674 | title=tag["title"], 675 | album=tag["album"], 676 | year=tag["year"], 677 | genre=tag["genre"], 678 | duration_ms=tag["duration_ms"], 679 | inode=InodeT(-1), 680 | ), 681 | dir_inode, 682 | ) 683 | 684 | def _unlink_track(self, track_id: str, dir_inode: InodeT) -> None: 685 | track = self._get_track_by_id(track_id) 686 | if track is None: 687 | return 688 | with self._db_cursor() as cur: 689 | if self.remove(dir_inode, track.inode): 690 | cur.execute( 691 | "DELETE FROM tracks WHERE inode=? AND track_id=?", 692 | (track.inode, track.track_id), 693 | ) 694 | self._invalidate_inode(track.inode) 695 | 696 | def _get_tracks( 697 | self, playlist_id: str | None = None 698 | ) -> dict[str, SQLTrack]: 699 | result = {} 700 | with self._db_cursor() as cur: 701 | cur.execute( 702 | """ 703 | SELECT 704 | * 705 | FROM tracks 706 | ORDER BY track_id 707 | """, 708 | ) 709 | 710 | for row in cur.fetchall(): 711 | if playlist_id and row["playlist_id"] != playlist_id: 712 | continue 713 | result[row["track_id"]] = SQLTrack(**row) 714 | return result 715 | 716 | @staticmethod 717 | async def _background_tasks( 718 | tasks: set[Task[Any]], 719 | ) -> tuple[set[Task[Any]], bool]: 720 | error = False 721 | finished, unfinished = await wait(tasks, return_when=FIRST_COMPLETED) 722 | for x in finished: 723 | if (err := x.exception()) is not None: 724 | log.error("Error task %s: %r", x.get_name(), err) 725 | error = True 726 | x.cancel() 727 | return unfinished, error 728 | 729 | async def __update_like_playlists(self) -> None: 730 | playlist_id = "likes" 731 | 732 | playlist_info = self._get_playlist_by_id( 733 | playlist_id 734 | ) or self._create_plyalist( 735 | SQLPlaylist( 736 | name=PLAYLIST_ID2NAME[playlist_id], 737 | playlist_id=playlist_id, 738 | station_id="", 739 | batch_id="", 740 | revision=0, 741 | inode=InodeT(-1), 742 | ), 743 | ) 744 | 745 | dir_inode = playlist_info.inode 746 | revision = playlist_info.revision 747 | 748 | # BUG: if if_modified_since_revision > 0 749 | # Traceback: AttributeError: 'str' object has no attribute 'get' 750 | users_likes_tracks = await self._ya_player.users_likes_tracks( 751 | if_modified_since_revision=revision - 1, 752 | ) 753 | if users_likes_tracks is None: 754 | log.warning("Like playlist track list empty") 755 | return 756 | 757 | if revision == users_likes_tracks.revision: 758 | log.debug("Playlist revision %d, no changes.", revision) 759 | return 760 | 761 | log.info("Totol like track: %d", len(users_likes_tracks.tracks)) 762 | 763 | like_tracks_ids = { 764 | track.track_id for track in users_likes_tracks.tracks 765 | } 766 | loaded_tracks = self._get_tracks(playlist_id) 767 | tracks = await users_likes_tracks.fetch_tracks() 768 | 769 | new_tracks_ids = like_tracks_ids - loaded_tracks.keys() 770 | unlink_tracks_ids = loaded_tracks.keys() - like_tracks_ids 771 | 772 | log.debug( 773 | "New track like playlist: %d. Remove track: %d, revision: %d/%d", 774 | len(new_tracks_ids), 775 | len(unlink_tracks_ids), 776 | revision, 777 | users_likes_tracks.revision, 778 | ) 779 | 780 | tracks = ( 781 | await self._ya_player.tracks(list(new_tracks_ids)) 782 | if new_tracks_ids 783 | else [] 784 | ) 785 | 786 | tasks = set() 787 | error_update = False 788 | 789 | for track_id in unlink_tracks_ids: 790 | self._unlink_track(track_id, dir_inode) 791 | 792 | async for track in self._ya_player.load_tracks( 793 | tracks, 794 | exclude_track_ids=set(loaded_tracks.keys()), 795 | ): 796 | tasks.add( 797 | create_task( 798 | self._update_track(track, playlist_id, dir_inode), 799 | name=f"create-track-{track.save_name}", 800 | ), 801 | ) 802 | if len(tasks) > LIMIT_TASKS: 803 | tasks, error = await self._background_tasks(tasks) 804 | error_update = error or error_update 805 | while tasks: 806 | tasks, error = await self._background_tasks(tasks) 807 | error_update = error or error_update 808 | 809 | if not error_update: 810 | self._update_plyalist(playlist_id, users_likes_tracks.revision) 811 | else: 812 | log.warning("Playlist is partially updated!") 813 | 814 | self._invalidate_inode(dir_inode) 815 | log.info( 816 | "Loaded track in like playlist %d.", 817 | users_likes_tracks.revision, 818 | ) 819 | 820 | async def __update_station_tracks( 821 | self, playlist_id: str, playlist_name: str 822 | ) -> None: 823 | playlist_info = self._get_playlist_by_id( 824 | playlist_id 825 | ) or self._create_plyalist( 826 | SQLPlaylist( 827 | name=playlist_name, 828 | playlist_id=playlist_id, 829 | station_id=playlist_id, 830 | batch_id="", 831 | revision=0, 832 | inode=InodeT(-1), 833 | ) 834 | ) 835 | 836 | if playlist_info is None: 837 | raise RuntimeError("Playlist info is empty!") 838 | 839 | dir_inode = playlist_info.inode 840 | 841 | tasks = set() 842 | error_update = False 843 | 844 | while ( 845 | len(loaded_tracks := self._get_tracks(playlist_id)) 846 | < LIMIT_ONYOURWAVE 847 | ): 848 | async for track in self._ya_player.next_tracks( 849 | playlist_id, 850 | count=LIMIT_ONYOURWAVE, 851 | exclude_track_ids=set(loaded_tracks.keys()), 852 | ): 853 | tasks.add( 854 | create_task( 855 | self._update_track( 856 | track, playlist_id, dir_inode, uniq=True 857 | ), 858 | name=f"create-track-{track.save_name}", 859 | ), 860 | ) 861 | if len(tasks) > LIMIT_TASKS: 862 | tasks, error = await self._background_tasks(tasks) 863 | error_update = error or error_update 864 | 865 | while tasks: 866 | tasks, error = await self._background_tasks(tasks) 867 | error_update = error or error_update 868 | 869 | if not error_update: 870 | _, batch_id = self._ya_player.get_last_station_info() 871 | self._update_plyalist(playlist_id, 0, batch_id) 872 | log.info("Playlist onyourwave is updated.") 873 | self._invalidate_inode(dir_inode) 874 | 875 | async def __cleanup_track(self) -> None: 876 | loaded_tracks = self._get_tracks() 877 | 878 | if not loaded_tracks: 879 | return 880 | 881 | loaded_tracks_by_id = {} 882 | for track_id, track in loaded_tracks.items(): 883 | track_id_without_album_id, *unused = track_id.split(":", 1) 884 | loaded_tracks_by_id[track_id_without_album_id] = track 885 | 886 | ya_tracks = await self._ya_player.tracks(track_ids=loaded_tracks.keys()) 887 | for ya_track in ya_tracks: 888 | if ya_track.available: 889 | continue 890 | 891 | try: 892 | info_track = loaded_tracks[ya_track.track_id] 893 | except KeyError: 894 | info_track = loaded_tracks_by_id[ya_track.id] 895 | 896 | log.warning( 897 | "Track %s is not available for listening. Cleanup inode %d", 898 | ya_track.title, 899 | info_track.inode, 900 | ) 901 | playlist_info = self._get_playlist_by_id(info_track.playlist_id) 902 | if playlist_info is None: 903 | raise RuntimeError("Playlist doesn't exist") 904 | self._unlink_track(info_track.track_id, playlist_info.inode) 905 | 906 | with self._db_cursor() as cur: 907 | cur.execute( 908 | """ 909 | DELETE FROM inodes 910 | WHERE inodes.id IN ( 911 | SELECT a.id 912 | FROM ( 913 | SELECT 914 | inodes.id, 915 | dentrys.inode 916 | FROM inodes 917 | LEFT JOIN dentrys ON dentrys.inode = inodes.id 918 | ) a 919 | INNER JOIN 920 | ( 921 | SELECT 922 | inodes.id, 923 | dentrys.inode 924 | FROM inodes 925 | LEFT JOIN dentrys ON dentrys.parent_inode = inodes.id 926 | ) b 927 | ON a.id = b.id 928 | WHERE a.inode IS NULL AND b.inode IS NULL 929 | ) 930 | """ 931 | ) 932 | cur.execute( 933 | """ 934 | DELETE FROM tracks 935 | WHERE 936 | tracks.inode IN ( 937 | SELECT tracks.inode 938 | FROM tracks 939 | LEFT JOIN inodes ON tracks.inode = inodes.id 940 | WHERE inodes.id IS NULL 941 | ); 942 | """ 943 | ) 944 | cur.execute(""" 945 | DELETE FROM direct_link 946 | WHERE 947 | id IN ( 948 | SELECT direct_link.id 949 | FROM direct_link 950 | LEFT JOIN tracks 951 | ON direct_link.track_id = tracks.track_id 952 | WHERE tracks.track_id IS NULL 953 | ); 954 | """) 955 | log.debug("Cleanup finished %d/%d", len(ya_tracks), len(loaded_tracks)) 956 | 957 | async def __fsm(self) -> None: 958 | while True: 959 | try: 960 | if not self._ya_player.is_init: 961 | await sleep(5) 962 | continue 963 | 964 | await self.__cleanup_track() 965 | await self.__update_like_playlists() 966 | 967 | playlist_onyourwave_id = "user:onyourwave" 968 | await self.__update_station_tracks( 969 | playlist_onyourwave_id, 970 | PLAYLIST_ID2NAME[playlist_onyourwave_id], 971 | ) 972 | # playlists = await self._ya_player.users_playlists_list() 973 | # for playlist in playlists: 974 | # print(playlist.kind, playlist.title) 975 | 976 | # stations = await self._ya_player.rotor_stations_list() 977 | 978 | # for station in stations: 979 | # _station = station.station 980 | # plyalist_id = f"{_station.id.type}:{_station.id.tag}" 981 | # plyalist_name = _station.name 982 | # print(plyalist_id, plyalist_name) 983 | # await self.__update_station_tracks() 984 | 985 | await sleep(300) 986 | except CancelledError: 987 | raise 988 | except Exception: 989 | log.exception("Error sync:") 990 | await sleep(60) 991 | 992 | async def _get_buffer(self, track: SQLTrack) -> Buffer | None: 993 | if ( 994 | direct_link := await self.get_or_update_direct_link( 995 | track.track_id, 996 | track.codec, 997 | track.bitrate, 998 | ) 999 | ) is None: 1000 | log.warning("Track %s is not be downloaded!", track.name) 1001 | return None 1002 | return Buffer(direct_link, self._client_session, track, self) 1003 | 1004 | @fail_is_exit 1005 | async def open( 1006 | self, 1007 | inode: InodeT, 1008 | flags: FlagT, 1009 | ctx: RequestContext, 1010 | ) -> FileInfo: 1011 | track = self._get_track_by_inode(inode) 1012 | 1013 | if track is None: 1014 | raise FUSEError(errno.ENOENT) 1015 | 1016 | if not self._ya_player.is_init: 1017 | raise FUSEError(errno.EPERM) 1018 | 1019 | buffer = await self._get_buffer(track) 1020 | if buffer is None: 1021 | raise FUSEError(errno.EPIPE) 1022 | 1023 | file_info = await super().open(inode, flags, ctx) 1024 | if flags & os.O_RDWR or flags & os.O_WRONLY: 1025 | raise FUSEError(errno.EPERM) 1026 | 1027 | log.debug("Open stream %s -> %d", track.name, file_info.fh) 1028 | self._fd_map_stream[file_info.fh] = StreamReader( 1029 | track=track, 1030 | buffer=buffer, 1031 | ) 1032 | 1033 | return file_info 1034 | 1035 | @fail_is_exit 1036 | async def read(self, fd: FileHandleT, offset: int, size: int) -> bytes: 1037 | stream_reader = self._fd_map_stream[fd] 1038 | 1039 | try: 1040 | chunk = await stream_reader.buffer.read_from(offset, size) 1041 | except RuntimeError: 1042 | raise FUSEError(errno.EPIPE) from None 1043 | 1044 | if len(chunk) > size: 1045 | log.warning( 1046 | "Chunk is corrupt. Invalid size chunk %d > %d", 1047 | len(chunk), 1048 | size, 1049 | ) 1050 | raise FUSEError(errno.EPIPE) 1051 | 1052 | try: 1053 | if ( 1054 | not stream_reader.is_send_feedback 1055 | and stream_reader.buffer.is_downloded 1056 | and stream_reader.buffer.total_second() > 30 # noqa: PLR2004 1057 | ): 1058 | playlist = self._get_playlist_by_id( 1059 | stream_reader.track.playlist_id 1060 | ) 1061 | 1062 | if playlist is not None and playlist.batch_id: 1063 | await self._ya_player.feedback_track( 1064 | stream_reader.track.track_id, 1065 | "trackStarted", 1066 | playlist.station_id, 1067 | playlist.batch_id, 1068 | 0, 1069 | ) 1070 | stream_reader.is_send_feedback = True 1071 | except CancelledError: 1072 | raise 1073 | except Exception: 1074 | log.exception("Error send feedback:") 1075 | 1076 | return chunk 1077 | 1078 | @fail_is_exit 1079 | async def release(self, fd: FileHandleT) -> None: 1080 | await super().release(fd) 1081 | 1082 | stream_reader = self._fd_map_stream.pop(fd, None) 1083 | if stream_reader is None: 1084 | log.warning("FD %d is none.", fd) 1085 | return 1086 | log.debug("Release stream %d > %s", fd, stream_reader.track.name) 1087 | 1088 | try: 1089 | if ( 1090 | not stream_reader.is_send_feedback 1091 | and stream_reader.buffer.is_downloded 1092 | ): 1093 | playlist = self._get_playlist_by_id( 1094 | stream_reader.track.playlist_id 1095 | ) 1096 | 1097 | if playlist is not None and playlist.batch_id: 1098 | await self._ya_player.feedback_track( 1099 | stream_reader.track.track_id, 1100 | "trackFinished", 1101 | playlist.station_id, 1102 | playlist.batch_id, 1103 | stream_reader.buffer.total_second(), 1104 | ) 1105 | stream_reader.is_send_feedback = True 1106 | except CancelledError: 1107 | raise 1108 | except Exception: 1109 | log.exception("Error send feedback:") 1110 | 1111 | @fail_is_exit 1112 | async def setxattr( 1113 | self, 1114 | inode: InodeT, 1115 | name: XAttrNameT, 1116 | value: bytes, 1117 | ctx: RequestContext, # noqa: ARG002 1118 | ) -> None: 1119 | track = self._get_track_by_inode(inode) 1120 | if track is None: 1121 | raise FUSEError(errno.ENOENT) 1122 | 1123 | playlist_info = self._get_playlist_by_id(track.playlist_id) 1124 | if playlist_info is None: 1125 | raise FUSEError(errno.ENOENT) 1126 | 1127 | if name == b".invalidate": 1128 | self._invalidate_inode(inode) 1129 | 1130 | if name == b"update": 1131 | if value == b".recreate": 1132 | self._unlink_track(track.track_id, playlist_info.inode) 1133 | 1134 | ya_tracks = await self._ya_player.tracks(track_ids=[track.track_id]) 1135 | async for ya_track in self._ya_player.load_tracks( 1136 | ya_tracks, exclude_track_ids=set() 1137 | ): 1138 | await self._update_track( 1139 | ya_track, track.playlist_id, playlist_info.inode 1140 | ) 1141 | 1142 | def xattrs(self, inode: InodeT) -> dict[str, Any]: 1143 | return { 1144 | "inode": inode, 1145 | "inode_map_fd": self._inode_map_fd.get(inode), 1146 | "stream": { 1147 | fd: { 1148 | "name": stream.track.name.decode(), 1149 | "size": stream.track.size, 1150 | "codec": stream.track.codec, 1151 | "bitrate": stream.track.bitrate, 1152 | "play_second": stream.buffer.total_second(), 1153 | } 1154 | for fd, stream in self._fd_map_stream.items() 1155 | }, 1156 | } 1157 | --------------------------------------------------------------------------------