├── tests ├── __init__.py ├── ai │ ├── __init__.py │ └── test_prompt.py ├── app │ ├── __init__.py │ ├── test_gui_app.py │ ├── test_app.py │ ├── test_server_app.py │ └── conftest.py ├── test_fuoexec.py ├── test_player.py ├── fuoexec │ ├── __init__.py │ ├── test_fuoexec.py │ └── test_functions.py ├── gui │ ├── pages │ │ └── __init__.py │ ├── uimain │ │ ├── __init__.py │ │ └── test_uimain.py │ ├── widgets │ │ ├── __init__.py │ │ ├── test_tabbar.py │ │ ├── test_meta.py │ │ ├── test_song_minicard_list.py │ │ ├── test_widgets.py │ │ └── test_progress_slider.py │ └── test_browser.py ├── player │ ├── __init__.py │ ├── test_mpvplayer.py │ └── test_recently_played.py ├── server │ ├── __init__.py │ ├── rpc │ │ ├── __init__.py │ │ └── dslv1 │ │ │ ├── __init__.py │ │ │ └── test_lexer.py │ ├── handlers │ │ ├── __init__.py │ │ ├── conftest.py │ │ ├── test_help.py │ │ ├── test_handle.py │ │ └── test_handlers.py │ ├── test_data_structure.py │ ├── test_dslv1.py │ └── test_dslv2.py ├── uimodels │ └── __init__.py ├── entry_points │ └── __init__.py ├── serializers │ ├── __init__.py │ ├── test_deserializers.py │ └── test_serializers.py ├── test_media.py ├── test_nowplaying.py ├── library │ ├── test_provider.py │ ├── test_model_v2.py │ ├── test_protocol.py │ └── test_library.py ├── test_bench.py ├── helpers.py ├── test_config.py ├── test_argparse.py ├── test_signal.py ├── test_plugin.py └── test_task.py ├── feeluown ├── library │ ├── abc.py │ ├── excs.py │ ├── flags.py │ ├── model_state.py │ ├── collection.py │ ├── __init__.py │ ├── standby.py │ └── text2song.py ├── app │ ├── once_app.py │ ├── __init__.py │ ├── mixed_app.py │ ├── mode.py │ ├── cli_app.py │ └── server_app.py ├── utils │ ├── __init__.py │ ├── yt_dlp_cookies.py │ ├── typing_.py │ ├── compat.py │ ├── lang.py │ ├── audio.py │ ├── request.py │ ├── aio.py │ └── cache.py ├── entry_points │ ├── __init__.py │ ├── run_cli.py │ ├── run.py │ └── base.py ├── gui │ ├── pages │ │ ├── __init__.py │ │ ├── template.py │ │ ├── recently_played.py │ │ ├── search.py │ │ ├── recommendation_daily_songs.py │ │ ├── toplist.py │ │ └── provider_home.py │ ├── uimain │ │ └── __init__.py │ ├── uimodels │ │ ├── README.rst │ │ ├── __init__.py │ │ ├── provider.py │ │ ├── playlist.py │ │ └── my_music.py │ ├── page_containers │ │ ├── __init__.py │ │ ├── README.rst │ │ └── scroll_area.py │ ├── assets │ │ ├── icons │ │ │ ├── like.png │ │ │ ├── mask.bmp │ │ │ ├── mask.psd │ │ │ ├── tray.psd │ │ │ ├── FeelUOwn.psd │ │ │ ├── download.png │ │ │ ├── feeluown.icns │ │ │ ├── feeluown.ico │ │ │ ├── feeluown.png │ │ │ ├── like_dark.png │ │ │ ├── tray-dark.png │ │ │ ├── tray-light.png │ │ │ ├── cur_playlist.png │ │ │ ├── like_checked.png │ │ │ ├── download_dark.png │ │ │ ├── already_download.png │ │ │ ├── cur_playlist_dark.png │ │ │ ├── like_checked_dark.png │ │ │ ├── already_downloaded_dark.png │ │ │ └── README.md │ │ └── themes │ │ │ ├── light.qss │ │ │ └── dark.qss │ ├── widgets │ │ ├── statusline_items │ │ │ ├── __init__.py │ │ │ ├── notify.py │ │ │ └── plugin.py │ │ ├── README.rst │ │ ├── textbtn.py │ │ ├── size_grip.py │ │ ├── __init__.py │ │ ├── my_music.py │ │ ├── header.py │ │ ├── separator.py │ │ ├── messageline.py │ │ ├── accordion.py │ │ └── lyric.py │ ├── __init__.py │ ├── components │ │ ├── volume_slider.py │ │ ├── __init__.py │ │ └── player_progress.py │ ├── consts.py │ ├── mimedata.py │ ├── debug.py │ └── tips.py ├── pyinstaller │ ├── __init__.py │ ├── hook.py │ └── main.py ├── server │ ├── rpc │ │ └── __init__.py │ ├── handlers │ │ ├── __init__.py │ │ ├── excs.py │ │ ├── status.py │ │ ├── cmd.py │ │ ├── set_.py │ │ ├── help.py │ │ ├── exec_.py │ │ ├── sub.py │ │ ├── base.py │ │ ├── jsonrpc_.py │ │ ├── search.py │ │ └── playlist.py │ ├── pubsub │ │ ├── __init__.py │ │ ├── publishers.py │ │ └── gateway.py │ ├── dslv1 │ │ ├── __init__.py │ │ └── codegen.py │ ├── session.py │ ├── __init__.py │ ├── excs.py │ ├── data_structure.py │ └── server.py ├── webserver │ └── __init__.py ├── cli │ └── __init__.py ├── ai │ ├── __init__.py │ └── ai.py ├── fuoexec │ ├── __init__.py │ ├── functions.py │ └── fuoexec.py ├── __main__.py ├── debug.py ├── consts.py ├── serializers │ ├── json_.py │ ├── __init__.py │ ├── typename.py │ └── _plain_formatter.py ├── local │ ├── ui.py │ ├── schemas.py │ ├── provider_ui.py │ └── __init__.py ├── nowplaying │ ├── linux │ │ └── __init__.py │ ├── __init__.py │ └── macos.py ├── player │ ├── __init__.py │ ├── recently_played.py │ ├── delegate.py │ └── metadata.py ├── alert.py ├── version.py └── __init__.py ├── docs ├── requirements.txt ├── source │ ├── media_assets_management │ │ ├── model.rst │ │ ├── library.rst │ │ ├── provider.rst │ │ └── index.rst │ ├── faq.rst │ ├── philosophy.rst │ ├── roadmap.rst │ ├── index.rst │ ├── glossary.rst │ ├── coding_style.rst │ ├── dev_quickstart.rst │ ├── contributing.rst │ └── features.rst └── Makefile ├── data ├── test.m4a └── test.webm ├── examples ├── README.md ├── shell.py ├── research │ ├── bench_getattr.py │ ├── multiple_inheritance.py │ └── aio_tcp_server.py ├── library_basic_usage.py └── macos_nowplaying.py ├── setup.py ├── .metadata.yml ├── .vscode ├── settings.json └── launch.json ├── .github ├── ISSUE_TEMPLATE │ ├── feeluown-enhancement-proposal.md │ └── bug_report.md └── workflows │ ├── pypi-release.yml │ ├── win-release.yml │ └── macos-release.yml ├── .readthedocs.yaml ├── .pylintrc ├── .travis.yml ├── .gitignore ├── pyinstaller_hooks └── hook-feeluown.py └── README.md /tests/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /feeluown/library/abc.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /tests/ai/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /tests/app/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /tests/test_fuoexec.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /tests/test_player.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /feeluown/app/once_app.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /feeluown/utils/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /tests/fuoexec/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /tests/gui/pages/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /tests/gui/uimain/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /tests/player/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /tests/server/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /tests/server/rpc/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /tests/uimodels/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /feeluown/entry_points/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /feeluown/gui/pages/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /feeluown/gui/uimain/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /feeluown/gui/uimodels/README.rst: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /feeluown/gui/uimodels/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /feeluown/gui/uimodels/provider.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /feeluown/pyinstaller/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /feeluown/server/rpc/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /tests/entry_points/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /tests/fuoexec/test_fuoexec.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /tests/gui/widgets/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /tests/player/test_mpvplayer.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /tests/serializers/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /tests/server/handlers/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /tests/server/handlers/conftest.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /tests/server/handlers/test_help.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /tests/server/rpc/dslv1/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /tests/server/test_data_structure.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /feeluown/gui/page_containers/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /docs/requirements.txt: -------------------------------------------------------------------------------- 1 | sphinx 2 | sphinx_rtd_theme 3 | -------------------------------------------------------------------------------- /feeluown/webserver/__init__.py: -------------------------------------------------------------------------------- 1 | from .server import run_web_server # noqa 2 | -------------------------------------------------------------------------------- /data/test.m4a: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/feeluown/FeelUOwn/HEAD/data/test.m4a -------------------------------------------------------------------------------- /data/test.webm: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/feeluown/FeelUOwn/HEAD/data/test.webm -------------------------------------------------------------------------------- /feeluown/utils/yt_dlp_cookies.py: -------------------------------------------------------------------------------- 1 | from yt_dlp.cookies import load_cookies # noqa 2 | -------------------------------------------------------------------------------- /feeluown/cli/__init__.py: -------------------------------------------------------------------------------- 1 | from .cli import climain, oncemain # noqa 2 | from .cli import Client, Request # noqa 3 | -------------------------------------------------------------------------------- /feeluown/gui/assets/icons/like.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/feeluown/FeelUOwn/HEAD/feeluown/gui/assets/icons/like.png -------------------------------------------------------------------------------- /feeluown/gui/assets/icons/mask.bmp: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/feeluown/FeelUOwn/HEAD/feeluown/gui/assets/icons/mask.bmp -------------------------------------------------------------------------------- /feeluown/gui/assets/icons/mask.psd: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/feeluown/FeelUOwn/HEAD/feeluown/gui/assets/icons/mask.psd -------------------------------------------------------------------------------- /feeluown/gui/assets/icons/tray.psd: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/feeluown/FeelUOwn/HEAD/feeluown/gui/assets/icons/tray.psd -------------------------------------------------------------------------------- /feeluown/ai/__init__.py: -------------------------------------------------------------------------------- 1 | # flake8: noqa 2 | 3 | from .copilot import AISongModel, Copilot, AISongMatcher 4 | from .ai import AI 5 | -------------------------------------------------------------------------------- /feeluown/gui/assets/icons/FeelUOwn.psd: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/feeluown/FeelUOwn/HEAD/feeluown/gui/assets/icons/FeelUOwn.psd -------------------------------------------------------------------------------- /feeluown/gui/assets/icons/download.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/feeluown/FeelUOwn/HEAD/feeluown/gui/assets/icons/download.png -------------------------------------------------------------------------------- /feeluown/gui/assets/icons/feeluown.icns: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/feeluown/FeelUOwn/HEAD/feeluown/gui/assets/icons/feeluown.icns -------------------------------------------------------------------------------- /feeluown/gui/assets/icons/feeluown.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/feeluown/FeelUOwn/HEAD/feeluown/gui/assets/icons/feeluown.ico -------------------------------------------------------------------------------- /feeluown/gui/assets/icons/feeluown.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/feeluown/FeelUOwn/HEAD/feeluown/gui/assets/icons/feeluown.png -------------------------------------------------------------------------------- /feeluown/gui/assets/icons/like_dark.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/feeluown/FeelUOwn/HEAD/feeluown/gui/assets/icons/like_dark.png -------------------------------------------------------------------------------- /feeluown/gui/assets/icons/tray-dark.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/feeluown/FeelUOwn/HEAD/feeluown/gui/assets/icons/tray-dark.png -------------------------------------------------------------------------------- /feeluown/gui/assets/icons/tray-light.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/feeluown/FeelUOwn/HEAD/feeluown/gui/assets/icons/tray-light.png -------------------------------------------------------------------------------- /feeluown/server/handlers/__init__.py: -------------------------------------------------------------------------------- 1 | from .handle import handle_request 2 | 3 | 4 | __all__ = ( 5 | 'handle_request', 6 | ) 7 | -------------------------------------------------------------------------------- /feeluown/gui/assets/icons/cur_playlist.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/feeluown/FeelUOwn/HEAD/feeluown/gui/assets/icons/cur_playlist.png -------------------------------------------------------------------------------- /feeluown/gui/assets/icons/like_checked.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/feeluown/FeelUOwn/HEAD/feeluown/gui/assets/icons/like_checked.png -------------------------------------------------------------------------------- /feeluown/fuoexec/__init__.py: -------------------------------------------------------------------------------- 1 | from .fuoexec import fuoexec_load_rcfile, fuoexec_init, fuoexec # noqa 2 | from .functions import * # noqa 3 | -------------------------------------------------------------------------------- /feeluown/gui/assets/icons/download_dark.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/feeluown/FeelUOwn/HEAD/feeluown/gui/assets/icons/download_dark.png -------------------------------------------------------------------------------- /feeluown/server/handlers/excs.py: -------------------------------------------------------------------------------- 1 | from feeluown.excs import FuoException 2 | 3 | 4 | class HandlerException(FuoException): 5 | pass 6 | -------------------------------------------------------------------------------- /feeluown/gui/assets/icons/already_download.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/feeluown/FeelUOwn/HEAD/feeluown/gui/assets/icons/already_download.png -------------------------------------------------------------------------------- /feeluown/gui/assets/icons/cur_playlist_dark.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/feeluown/FeelUOwn/HEAD/feeluown/gui/assets/icons/cur_playlist_dark.png -------------------------------------------------------------------------------- /feeluown/gui/assets/icons/like_checked_dark.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/feeluown/FeelUOwn/HEAD/feeluown/gui/assets/icons/like_checked_dark.png -------------------------------------------------------------------------------- /examples/README.md: -------------------------------------------------------------------------------- 1 | fuocore 使用示例 2 | ================ 3 | 4 | 该目录下包含了各模块的使用示例。 5 | 6 | 另外,shell.py 构造一个典型的使用场景,开发者可以运行`ipython -i shell.py` 命令, 7 | 进行一些测试。 8 | -------------------------------------------------------------------------------- /feeluown/gui/assets/icons/already_downloaded_dark.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/feeluown/FeelUOwn/HEAD/feeluown/gui/assets/icons/already_downloaded_dark.png -------------------------------------------------------------------------------- /feeluown/entry_points/run_cli.py: -------------------------------------------------------------------------------- 1 | from feeluown.utils import aio 2 | from feeluown.cli import climain 3 | 4 | 5 | def run_cli(args): 6 | aio.run(climain(args)) 7 | -------------------------------------------------------------------------------- /feeluown/server/pubsub/__init__.py: -------------------------------------------------------------------------------- 1 | from .gateway import Gateway 2 | from .publishers import LiveLyricPublisher 3 | 4 | 5 | __all__ = ( 6 | 'Gateway', 7 | 'LiveLyricPublisher', 8 | ) 9 | -------------------------------------------------------------------------------- /feeluown/gui/widgets/statusline_items/__init__.py: -------------------------------------------------------------------------------- 1 | from .plugin import PluginStatus 2 | from .notify import NotifyStatus 3 | 4 | 5 | __all__ = ( 6 | "PluginStatus", 7 | "NotifyStatus", 8 | ) 9 | -------------------------------------------------------------------------------- /feeluown/server/handlers/status.py: -------------------------------------------------------------------------------- 1 | from .base import AbstractHandler 2 | 3 | 4 | class StatusHandler(AbstractHandler): 5 | cmds = 'status' 6 | 7 | def handle(self, cmd): 8 | return self._app 9 | -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | """ 3 | Simply a backward compatibility wrapper for setuptools. 4 | To configure the project, use pyproject.toml instead. 5 | """ 6 | 7 | import setuptools 8 | setuptools.setup() -------------------------------------------------------------------------------- /docs/source/media_assets_management/model.rst: -------------------------------------------------------------------------------- 1 | 数据模型 2 | ===================== 3 | 4 | feeluown 定义了常见音乐资源的数据模型,包括歌曲、歌手、专辑、视频、MV、歌单等。 5 | 这样,上层模块就能以统一的方式访问这些资源。 6 | 7 | .. automodule:: feeluown.library.models 8 | :members: 9 | -------------------------------------------------------------------------------- /feeluown/pyinstaller/hook.py: -------------------------------------------------------------------------------- 1 | import pathlib 2 | 3 | 4 | def get_hook_dirs(): 5 | root = pathlib.Path(__file__).resolve().parent.parent.parent 6 | hooks_dir = str(root / 'pyinstaller_hooks') 7 | return [hooks_dir] 8 | -------------------------------------------------------------------------------- /feeluown/__main__.py: -------------------------------------------------------------------------------- 1 | """ 2 | Make `python -m feeluown` an alias for running `fuo`. 3 | """ 4 | 5 | 6 | from feeluown.entry_points.run import run 7 | 8 | 9 | main = run 10 | 11 | 12 | if __name__ == '__main__': 13 | run() 14 | -------------------------------------------------------------------------------- /tests/server/test_dslv1.py: -------------------------------------------------------------------------------- 1 | from feeluown.server import dslv1 2 | from feeluown.server import Request 3 | 4 | 5 | def test_request(): 6 | req = Request(cmd='play', cmd_args=['fuo://x']) 7 | assert 'play fuo://x' in dslv1.unparse(req) 8 | -------------------------------------------------------------------------------- /feeluown/utils/typing_.py: -------------------------------------------------------------------------------- 1 | # flake8: noqa 2 | from typing import TYPE_CHECKING 3 | 4 | 5 | try: 6 | from typing import Protocol 7 | except ImportError: 8 | if not TYPE_CHECKING: 9 | from typing_extensions import Protocol 10 | -------------------------------------------------------------------------------- /feeluown/server/dslv1/__init__.py: -------------------------------------------------------------------------------- 1 | from .lexer import Lexer 2 | from .parser import Parser, parse 3 | from .codegen import unparse 4 | 5 | 6 | __all__ = ( 7 | 'Lexer', 8 | 'Parser', 9 | 10 | 'parse', 11 | 'unparse', 12 | ) 13 | -------------------------------------------------------------------------------- /feeluown/app/__init__.py: -------------------------------------------------------------------------------- 1 | from .app import App, AppMode, create_app, get_app 2 | from .config import create_config 3 | 4 | 5 | __all__ = ( 6 | 'App', 7 | 'AppMode', 8 | 'create_app', 9 | 'get_app', 10 | 11 | 'create_config', 12 | ) 13 | -------------------------------------------------------------------------------- /.metadata.yml: -------------------------------------------------------------------------------- 1 | CompanyName: FeelUOwn 2 | FileDescription: FeelUOwn 3 | InternalName: FeelUOwn 4 | LegalCopyright: © FeelUOwn. All rights reserved. 5 | OriginalFilename: FeelUOwn.exe 6 | ProductName: FeelUOwn 7 | Translation: 8 | - langID: 2052 9 | charsetID: 1200 10 | -------------------------------------------------------------------------------- /feeluown/debug.py: -------------------------------------------------------------------------------- 1 | from contextlib import contextmanager 2 | from unittest.mock import MagicMock 3 | 4 | from feeluown.app import create_config 5 | 6 | 7 | @contextmanager 8 | def mock_app(): 9 | app = MagicMock() 10 | app.config = create_config() 11 | yield app 12 | -------------------------------------------------------------------------------- /.vscode/settings.json: -------------------------------------------------------------------------------- 1 | { 2 | "files.trimTrailingWhitespace": true, 3 | "editor.formatOnSave": false, 4 | "[python]": { 5 | "editor.formatOnSaveMode": "file", 6 | "editor.formatOnSave": false, 7 | "editor.defaultFormatter": "eeyore.yapf" 8 | }, 9 | } 10 | -------------------------------------------------------------------------------- /feeluown/gui/__init__.py: -------------------------------------------------------------------------------- 1 | from .provider_ui import ProviderUiManager 2 | from .uimodels.my_music import MyMusicUiManager 3 | from .uimodels.playlist import PlaylistUiManager 4 | 5 | __all__ = ( 6 | "ProviderUiManager", 7 | "MyMusicUiManager", 8 | "PlaylistUiManager", 9 | ) 10 | -------------------------------------------------------------------------------- /feeluown/server/session.py: -------------------------------------------------------------------------------- 1 | import asyncio 2 | from typing import Protocol 3 | 4 | from .data_structure import SessionOptions 5 | 6 | 7 | class SessionLike(Protocol): 8 | options: SessionOptions 9 | 10 | @property 11 | def writer(self) -> asyncio.StreamWriter: 12 | ... 13 | -------------------------------------------------------------------------------- /feeluown/app/mixed_app.py: -------------------------------------------------------------------------------- 1 | from .server_app import ServerApp 2 | from .gui_app import GuiApp 3 | 4 | 5 | class MixedApp(ServerApp, GuiApp): 6 | def __init__(self, *args, **kwargs): 7 | super().__init__(*args, **kwargs) 8 | 9 | def initialize(self): 10 | super().initialize() 11 | 12 | def run(self): 13 | super().run() 14 | -------------------------------------------------------------------------------- /feeluown/server/handlers/cmd.py: -------------------------------------------------------------------------------- 1 | class Cmd: 2 | """ 3 | TODO: Maybe we should remove this class and use Request instead. 4 | """ 5 | def __init__(self, action, *args, options=None): 6 | self.action = action 7 | self.args = args 8 | self.options = options or {} 9 | 10 | def __str__(self): 11 | return f'action:{self.action} args:{self.args}' 12 | -------------------------------------------------------------------------------- /feeluown/library/excs.py: -------------------------------------------------------------------------------- 1 | # Old code imports these exceptions from this module 2 | from feeluown.excs import ( # noqa 3 | LibraryException, 4 | ResourceNotFound, 5 | # FIXME: ProviderAlreadyExists should be renamed to ProviderAlreadyRegistered 6 | ProviderAlreadyRegistered as ProviderAlreadyExists, 7 | ModelNotFound, 8 | NoUserLoggedIn, 9 | MediaNotFound, 10 | ) # noqa 11 | -------------------------------------------------------------------------------- /feeluown/gui/page_containers/README.rst: -------------------------------------------------------------------------------- 1 | feeluown.containers 2 | =================== 3 | 4 | 5 | feeluown.containers 和 feeluown.gui.widgets 的区别 6 | """""""""""""""""""""""""""""""""""""""""""""" 7 | 8 | 1. Container 知道 `app` 这个概念的存在,并且应该将它作为构造函数的 9 | 第一个参数,而 widget 不应该知道 app 的存在,widget 通过信号 10 | 将信息传送给 container,container 调用 widget 的方法来实现控制。 11 | 2. Container 一般主要负责 widget 的排列、显示隐藏等逻辑,而不会对应 12 | 具体的功能。 13 | -------------------------------------------------------------------------------- /feeluown/gui/widgets/README.rst: -------------------------------------------------------------------------------- 1 | feeluown.gui.widgets 2 | ==================== 3 | 4 | 5 | widgets 包中模块的命名规范 6 | """""""""""""""""""""""""""""""" 7 | 8 | 1. 模块名中只应该包含组件要展示的内容,不要包含组件的展示形式。举个例子, 9 | ``songs`` 模块中包含的组件都是用来展示 **一组歌曲** 的, 10 | 目前有的 *展示形式* 有 ``table`` 和 ``list`` 两种。我们可以有 11 | ``songs`` 模块,但不要有 ``song(s)_list`` 模块。 12 | 13 | 2. ``album`` 表示 **单个专辑** ,比如专辑详情。而 ``albums`` 表示一组专辑, 14 | 比如一个歌手的所有专辑。 15 | -------------------------------------------------------------------------------- /examples/shell.py: -------------------------------------------------------------------------------- 1 | #! /usr/bin/env python3 2 | # -*- coding: utf-8 -*- 3 | 4 | import logging 5 | from feeluown.library import Library 6 | 7 | from fuo_xiami import provider as xp 8 | from fuo_netease import provider as np 9 | 10 | logging.basicConfig() 11 | logger = logging.getLogger('feeluown') 12 | logger.setLevel(logging.DEBUG) 13 | 14 | lib = Library() 15 | lib.register(xp) 16 | lib.register(np) 17 | 18 | library = lib 19 | -------------------------------------------------------------------------------- /feeluown/pyinstaller/main.py: -------------------------------------------------------------------------------- 1 | import sys 2 | import os 3 | 4 | 5 | if __name__ == '__main__': 6 | # Chdir first and then load feeluown, so that libmpv can be loaded correctly. 7 | # On macOS, the dir is changed to FeelUOwnX.app/Contents/Frameworks. 8 | if hasattr(sys, '_MEIPASS'): 9 | os.chdir(sys._MEIPASS) 10 | 11 | import feeluown.__main__ 12 | 13 | os.environ.setdefault('FUO_LOG_TO_FILE', '1') 14 | feeluown.__main__.main() 15 | -------------------------------------------------------------------------------- /tests/test_media.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | 3 | from feeluown.media import Media 4 | 5 | 6 | @pytest.fixture 7 | def media(): 8 | return Media('zzz://xxx.yyy', 9 | http_headers={'referer': 'http://xxx.yyy'}) 10 | 11 | 12 | def test_media_copy(media): 13 | media2 = Media(media) 14 | assert media2.http_headers == media.http_headers 15 | 16 | 17 | def test_media_http_headers(media): 18 | assert 'referer' in media.http_headers 19 | -------------------------------------------------------------------------------- /feeluown/ai/ai.py: -------------------------------------------------------------------------------- 1 | from feeluown.app import App 2 | from feeluown.ai.copilot import Copilot 3 | 4 | 5 | # FIXME: Other packages should only import things from feeluown.ai 6 | # They should not import things from feeluown.ai.copilot or other inner moduels. 7 | 8 | 9 | class AI: 10 | def __init__(self, app: App): 11 | self._app = app 12 | self._copilot = Copilot(self._app) 13 | 14 | def get_copilot(self): 15 | return self._copilot 16 | -------------------------------------------------------------------------------- /feeluown/gui/pages/template.py: -------------------------------------------------------------------------------- 1 | from typing import TYPE_CHECKING 2 | 3 | from PyQt6.QtCore import Qt 4 | 5 | from feeluown.gui.widgets.labels import MessageLabel 6 | 7 | if TYPE_CHECKING: 8 | from feeluown.app.gui_app import GuiApp 9 | 10 | 11 | async def render_error_message(app: "GuiApp", msg: str): 12 | label = MessageLabel(msg, MessageLabel.ERROR) 13 | label.setAlignment(Qt.AlignmentFlag.AlignCenter) 14 | app.ui.page_view.set_body(label) 15 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/feeluown-enhancement-proposal.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: FeelUOwn enhancement proposal 3 | about: FeelUOwn enhancement proposal 4 | title: '' 5 | labels: FEP 6 | assignees: '' 7 | 8 | --- 9 | 10 | - 作者:@xxx 11 | - 创建时间:2020-01-01 12 | - 最近更新:2020-01-01 18:00 13 | - 最新状态: 14 | - *2019-08-30* - 开发完成 15 | - *2019-05-30* - 设计缺陷已经修正 16 | - *2019-05-28* - 该设计存在很多问题,详见[评论]() 17 | - *2019-04-11* - 已经实现后端。详见 [PR]() 18 | 19 | ## 简介与背景 20 | 21 | ## 方案概述 22 | -------------------------------------------------------------------------------- /tests/test_nowplaying.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | 3 | from feeluown.nowplaying import get_service_cls 4 | from .helpers import is_macos, aio_waitfor_simple_tasks 5 | 6 | 7 | @pytest.mark.skipif(not is_macos, reason='only test on macos') 8 | @pytest.mark.asyncio 9 | async def test_macos_update_song_pic(app_mock): 10 | service = get_service_cls()(app_mock) 11 | service.update_song_metadata({}) 12 | await aio_waitfor_simple_tasks() 13 | assert app_mock.img_mgr.get.called 14 | -------------------------------------------------------------------------------- /docs/source/media_assets_management/library.rst: -------------------------------------------------------------------------------- 1 | 音乐库 2 | ===================== 3 | 4 | .. _library: 5 | 6 | 音乐库模块管理资源提供方(*Provider*)。音乐库还提供了一些通用接口,简化了对资源提供方的访问。 7 | 8 | .. code:: 9 | 10 | # 注册一个资源提供方 11 | library.register(provider) 12 | 13 | # 获取资源提供方实例 14 | provider = library.get(provider.identifier) 15 | 16 | # 列出所有资源提供方 17 | library.list() 18 | 19 | # 在音乐库中搜索关键词 20 | library.search('linkin park') 21 | 22 | .. autoclass:: feeluown.library.Library 23 | :members: 24 | -------------------------------------------------------------------------------- /feeluown/gui/widgets/textbtn.py: -------------------------------------------------------------------------------- 1 | from PyQt6.QtCore import Qt 2 | from PyQt6.QtWidgets import QPushButton 3 | 4 | 5 | class TextButton(QPushButton): 6 | def __init__(self, *args, height=None, **kwargs): 7 | """ 8 | .. versionadded:: 3.9 9 | The *height* argument. 10 | """ 11 | super().__init__(*args, **kwargs) 12 | 13 | self.setAttribute(Qt.WidgetAttribute.WA_LayoutUsesWidgetRect, True) 14 | if height: 15 | self.setFixedHeight(height) 16 | -------------------------------------------------------------------------------- /feeluown/app/mode.py: -------------------------------------------------------------------------------- 1 | from enum import IntFlag 2 | 3 | 4 | class AppMode(IntFlag): 5 | """ 6 | When server mode is on, rpc server and pubsub server are both started. 7 | When gui mode is on, GUI window is created and shown. Server and gui mode 8 | can be both on. When cli mode is on, server and gui modes are off (currently). 9 | 10 | cli mode is *experimental* feature temporarily. 11 | """ 12 | server = 0x0001 # 开启 Server 13 | gui = 0x0010 # 显示 GUI 14 | cli = 0x0100 # 命令行模式 15 | -------------------------------------------------------------------------------- /feeluown/server/handlers/set_.py: -------------------------------------------------------------------------------- 1 | from .cmd import Cmd 2 | from .base import AbstractHandler 3 | 4 | 5 | class SetHandler(AbstractHandler): 6 | cmds = ('set', ) 7 | 8 | def handle(self, cmd: Cmd): 9 | assert self.session is not None 10 | options = cmd.options 11 | 12 | for key, value in options.items(): 13 | # Treat None as the default value. 14 | # TODO: use a more meaningful default value. 15 | if value is not None: 16 | setattr(self.session.options, key, value) 17 | -------------------------------------------------------------------------------- /docs/source/faq.rst: -------------------------------------------------------------------------------- 1 | 常见问题 2 | ======== 3 | 4 | 使用相关问题 5 | ------------ 6 | 7 | 安装完成后,运行 feeluown 提示 Command 'feeluown' not found 8 | ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ 9 | 一般来说,安装之后, feeluown 命令会在 `~/.local/bin/` 目录下。 10 | 11 | 可以通过下面命令查看目录下是否有 feeluown:: 12 | 13 | ls -l ~/.local/bin/feeluown 14 | 15 | 如果输出正常则说明安装已经成功了, 大家可以修改 PATH 环境变量即可。 16 | 17 | 如果是使用 bash 或者 zsh,大家可以在 ~/.bashrc 或者 ~/.zshrc 文件中加入一行:: 18 | 19 | export PATH=~/.local/bin:$PATH 20 | 21 | 然后重新进入 shell,下次就可以直接运行 feeluown 了。 22 | 23 | 开发相关问题 24 | ------------ 25 | -------------------------------------------------------------------------------- /tests/player/test_recently_played.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | 3 | from feeluown.player import RecentlyPlayed, Playlist 4 | 5 | 6 | @pytest.fixture() 7 | def playlist(app_mock): 8 | playlist = Playlist(app_mock) 9 | return playlist 10 | 11 | 12 | def test_list_songs(playlist, song1, song2): 13 | recently_played = RecentlyPlayed(playlist) 14 | playlist.song_changed_v2.emit(song1, object()) 15 | playlist.song_changed_v2.emit(song2, object()) 16 | songs = recently_played.list_songs() 17 | assert len(songs) == 2 18 | assert songs[0] == song2 19 | -------------------------------------------------------------------------------- /.readthedocs.yaml: -------------------------------------------------------------------------------- 1 | # Required 2 | version: 2 3 | 4 | # Set the OS, Python version and other tools you might need 5 | build: 6 | os: ubuntu-22.04 7 | tools: 8 | python: "3.11" 9 | # You can also specify other tool versions: 10 | # nodejs: "20" 11 | # rust: "1.70" 12 | # golang: "1.20" 13 | 14 | # Build documentation in the "docs/" directory with Sphinx 15 | sphinx: 16 | configuration: docs/source/conf.py 17 | 18 | # Explicitly set the version of Python and its requirements 19 | python: 20 | install: 21 | - requirements: docs/requirements.txt 22 | -------------------------------------------------------------------------------- /tests/app/test_gui_app.py: -------------------------------------------------------------------------------- 1 | from feeluown.collection import CollectionManager 2 | from feeluown.app.gui_app import GuiApp 3 | 4 | 5 | def test_gui_app_initialize(qtbot, mocker, args, config, noharm): 6 | # TaskManager must be initialized with asyncio. 7 | mocker.patch('feeluown.app.app.TaskManager') 8 | mocker.patch.object(CollectionManager, 'scan') 9 | app = GuiApp(args, config) 10 | qtbot.addWidget(app) 11 | 12 | mocker.patch.object(app.live_lyric.sentence_changed, 'connect') 13 | mocker.patch.object(app, 'about_to_exit') 14 | app.initialize() 15 | -------------------------------------------------------------------------------- /tests/gui/widgets/test_tabbar.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | 3 | from tests.helpers import cannot_run_qt_test 4 | from feeluown.gui.widgets.tabbar import TableTabBar 5 | 6 | 7 | @pytest.mark.skipif(cannot_run_qt_test, reason='') 8 | def test_tabbar(qtbot): 9 | tabbar = TableTabBar() 10 | qtbot.addWidget(tabbar) 11 | tabbar.artist_mode() 12 | with qtbot.waitSignal(tabbar.show_albums_needed): 13 | tabbar.tabBarClicked.emit(1) 14 | 15 | tabbar.library_mode() 16 | with qtbot.waitSignal(tabbar.show_artists_needed): 17 | tabbar.tabBarClicked.emit(1) 18 | -------------------------------------------------------------------------------- /feeluown/gui/widgets/size_grip.py: -------------------------------------------------------------------------------- 1 | from PyQt6.QtCore import QRectF, Qt 2 | from PyQt6.QtWidgets import QSizeGrip 3 | from PyQt6.QtGui import QTextOption, QPainter 4 | 5 | 6 | class SizeGrip(QSizeGrip): 7 | def __init__(self, parent=None): 8 | super().__init__(parent) 9 | 10 | def paintEvent(self, e): 11 | painter = QPainter(self) 12 | option = QTextOption() 13 | option.setAlignment( 14 | Qt.AlignmentFlag.AlignHCenter | Qt.AlignmentFlag.AlignVCenter 15 | ) 16 | painter.drawText(QRectF(self.rect()), "●", option) 17 | -------------------------------------------------------------------------------- /feeluown/gui/assets/icons/README.md: -------------------------------------------------------------------------------- 1 | ## 图标来源说明 2 | 3 | ### flaticon 上的图标 4 | 5 | 6 | - play.png 7 | - pause.png 8 | - next.png 9 | - last.png 10 | - volume.png 11 | - cur\_playlist.png 12 | - already\_download.png 13 | - download.png 14 | - like.png 15 | - like\_checked.png 16 | 17 | 这几个图标大小都是 64x64,在原始的图片基础上,我们需要给图片加 margin。 18 | margin 长宽请参考图片: 19 | ![image](https://user-images.githubusercontent.com/4962134/43989414-2564b350-9d7c-11e8-9eb5-5adc138e9c75.png) 20 | 21 | 看起来大的图标(比如 volume.png),让它距离右边两个格子;看起来小的图标, 22 | 让它距离上边两个格子(比如 play/pause.png)。 23 | -------------------------------------------------------------------------------- /docs/source/philosophy.rst: -------------------------------------------------------------------------------- 1 | 对各种哲学的一些认识 2 | ==================== 3 | 4 | 鉴于在生活的不同阶段接对同一问题的理解一般都会有点不一样, 5 | 所以我把自己的想法加上时间戳。 6 | 7 | .. _unix-philosophy: 8 | 9 | Unix 哲学 10 | --------- 11 | 2018-7-15 @cosven: 12 | 13 | 1. 只做一件事,并把它做好 14 | 2. 与其它程序可以良好组合 15 | 16 | EAFP or LBYL 17 | ------------ 18 | 2020-12-26 @cosven: 19 | 20 | 在 FeelUOwn 中,每个 :term:`provider` 提供的能力,对它们进行抽象时,有两种方式 21 | 22 | 1. 假设 provider 提供了我们需要的所有能力,没有的时候,报错 23 | 2. 要求提供方声明自己具有哪些能力,:term:`library` 调用时先判断 24 | 25 | FeelUOwn 大部分情况选用的是方式 2,举个例子,FeelUOwn 如果知道 provider 没有 A 功能, 26 | 就可以在界面上将这个功能的按钮置位灰色。而当该这功能对界面展示影响甚微时, 27 | 会考虑使用方式 1。 28 | -------------------------------------------------------------------------------- /.github/workflows/pypi-release.yml: -------------------------------------------------------------------------------- 1 | name: Publish package to PyPI 2 | on: 3 | push: 4 | tags: 5 | - v* 6 | 7 | jobs: 8 | build-n-publish: 9 | runs-on: ubuntu-latest 10 | steps: 11 | - uses: actions/checkout@master 12 | - name: Set up Python 13 | uses: actions/setup-python@v5 14 | with: 15 | python-version: 3.11 16 | - name: Build 17 | run: >- 18 | python setup.py sdist 19 | - name: Publish 20 | uses: pypa/gh-action-pypi-publish@master 21 | with: 22 | user: __token__ 23 | password: ${{ secrets.PYPI_API_TOKEN }} 24 | -------------------------------------------------------------------------------- /feeluown/gui/components/volume_slider.py: -------------------------------------------------------------------------------- 1 | from PyQt6.QtCore import Qt 2 | from PyQt6.QtWidgets import QSlider 3 | 4 | from feeluown.app import App 5 | 6 | 7 | class VolumeSlider(QSlider): 8 | def __init__(self, app: App, parent=None): 9 | super().__init__(parent) 10 | 11 | self._app = app 12 | self.setMinimum(0) 13 | self.setMaximum(100) 14 | self.setValue(100) 15 | self.sliderMoved.connect(self.on_slider_moved) 16 | self.setOrientation(Qt.Orientation.Horizontal) 17 | 18 | def on_slider_moved(self, value): 19 | self._app.player.volume = value 20 | -------------------------------------------------------------------------------- /docs/source/roadmap.rst: -------------------------------------------------------------------------------- 1 | Roadmap 2 | ============= 3 | 4 | **FeelUOwn 项目发展方向** 5 | 6 | 1. 一个好用的、能 hack 的播放器 7 | 2. 一个优秀的 Python 项目 8 | 9 | 10 | 2020 下半年 11 | ------------------- 12 | 13 | 帮助用户发现音乐以及背后的故事 14 | """""""""""""""""""""""""""""" 15 | 16 | 1. 支持查找类似歌曲? 17 | 2. 集成豆瓣、百科、网易云评论等平台资源? 18 | 3. 支持集成 bilibili/youtube 等视频资源? 19 | 20 | 已有功能优化 21 | """""""""""""""""""""""""""""" 22 | 23 | 1. 系统托盘? 24 | 2. fuo 文件功能丰富? 25 | 3. 更好地找候选歌曲? 26 | 27 | 代码结构优化 28 | """""""""""""""""""""""""""""" 29 | 30 | 1. 去除 feeluown 概念,按照 daemon 和 gui 两种模式来组织代码 31 | 2. 调研 type hint 的必要性与可行性? 32 | 33 | fuo 协议优化 34 | """""""""""""""""""""""""""""" 35 | -------------------------------------------------------------------------------- /feeluown/gui/components/__init__.py: -------------------------------------------------------------------------------- 1 | """ 2 | A component is a certain widget depends on `feeluown.app.App` object. 3 | In other words, a component is tightly coupled with application logic. 4 | """ 5 | 6 | from .avatar import Avatar # noqa 7 | from .btns import * # noqa 8 | from .menu import SongMenuInitializer # noqa 9 | from .line_song import LineSongLabel # noqa 10 | from .playlist_btn import PlaylistButton # noqa 11 | from .volume_slider import * # noqa 12 | from .song_tag import SongSourceTag # noqa 13 | from .collections import CollectionListView # noqa 14 | from .player_progress import PlayerProgressSliderAndLabel # noqa 15 | -------------------------------------------------------------------------------- /feeluown/utils/compat.py: -------------------------------------------------------------------------------- 1 | import asyncio 2 | import sys 3 | 4 | from qasync import QEventLoop, QThreadExecutor 5 | from PyQt6.QtWidgets import QApplication 6 | 7 | try: 8 | from qasync import DefaultQEventLoopPolicy 9 | except ImportError: 10 | # From qasync>=0.28, the class DefaultQEventLoopPolicy is removed 11 | # when python version >= 3.12 12 | class DefaultQEventLoopPolicy(asyncio.DefaultEventLoopPolicy): 13 | def new_event_loop(self): 14 | return QEventLoop(QApplication.instance() or QApplication(sys.argv)) 15 | 16 | 17 | __all__ = ( 18 | 'QEventLoop', 19 | 'QThreadExecutor', 20 | 'DefaultQEventLoopPolicy' 21 | ) 22 | -------------------------------------------------------------------------------- /feeluown/app/cli_app.py: -------------------------------------------------------------------------------- 1 | from feeluown.utils import aio 2 | from .app import App 3 | 4 | 5 | class CliApp(App): 6 | 7 | def __init__(self, *args, **kwargs): 8 | super().__init__(*args, **kwargs) 9 | 10 | def initialize(self): 11 | super().initialize() 12 | 13 | def run(self): 14 | super().run() 15 | 16 | # oncemain should be moved from feeluown/cli to feeluown/app, 17 | # However, it depends on cli logic temporarily. If there is 18 | # an common module for these cli logic, oncemain can be moved. 19 | from feeluown.cli import oncemain # pylint: disable=cyclic-import 20 | 21 | aio.run_afn(oncemain, self) 22 | -------------------------------------------------------------------------------- /feeluown/consts.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | 3 | import os 4 | 5 | # USER_HOME variable is design for mobile application (feeluownx) 6 | USER_HOME = os.environ.get('FEELUOWN_USER_HOME', os.path.expanduser('~')) 7 | HOME_DIR = os.path.expanduser(USER_HOME + '/.FeelUOwn') 8 | 9 | DATA_DIR = HOME_DIR + '/data' 10 | USER_PLUGINS_DIR = HOME_DIR + '/plugins' 11 | USER_THEMES_DIR = HOME_DIR + '/themes' 12 | CACHE_DIR = HOME_DIR + '/cache' 13 | SONG_DIR = HOME_DIR + '/songs' 14 | COLLECTIONS_DIR = HOME_DIR + '/collections' 15 | 16 | LOG_FILE = HOME_DIR + '/stdout.log' 17 | STATE_FILE = os.path.join(DATA_DIR, 'state.json') 18 | DEFAULT_RCFILE_PATH = os.path.expanduser(f'{USER_HOME}/.fuorc') 19 | -------------------------------------------------------------------------------- /tests/library/test_provider.py: -------------------------------------------------------------------------------- 1 | from feeluown.media import Quality 2 | from feeluown.library import ModelType 3 | 4 | 5 | def test_library_song_select_media(mocker, ekaf_provider, song): 6 | valid_q_list = [Quality.Audio.sq] 7 | mocker.patch.object(ekaf_provider, 'song_list_quality', 8 | return_value=valid_q_list) 9 | mocker.patch.object(ekaf_provider, 'song_get_media') 10 | _, q = ekaf_provider.song_select_media(song, None) 11 | assert q == valid_q_list[0] 12 | 13 | 14 | def test_use_model_v2(ekaf_provider): 15 | assert ekaf_provider.use_model_v2(ModelType.album) is True 16 | assert ekaf_provider.use_model_v2(ModelType.dummy) is False 17 | -------------------------------------------------------------------------------- /feeluown/gui/assets/themes/light.qss: -------------------------------------------------------------------------------- 1 | TopPanel { 2 | background-color: qlineargradient( 3 | x1: 0, y1: 0, x2: 0, y2: 1, 4 | stop: 0 #EEE, stop: 0.4 #DDD, stop: 0.7 #CCC, stop: 1 #BBB 5 | ); 6 | } 7 | 8 | TextButton { 9 | border: 1px solid #AAA; 10 | } 11 | 12 | TextButton:pressed, ToolbarButton:pressed { 13 | background-color: #DDD; 14 | } 15 | 16 | #like_btn { 17 | border-image: url(icons:like.png); 18 | } 19 | #like_btn:checked { 20 | border-image: url(icons:like_checked_dark.png); 21 | } 22 | #download_btn { 23 | border-image: url(icons:download.png); 24 | } 25 | #download_btn:checked { 26 | border-image: url(icons:already_download.png); 27 | } 28 | -------------------------------------------------------------------------------- /tests/gui/widgets/test_meta.py: -------------------------------------------------------------------------------- 1 | from datetime import datetime 2 | 3 | import pytest 4 | 5 | from tests.helpers import is_travis_env 6 | from feeluown.gui.widgets.meta import TableMetaWidget 7 | 8 | 9 | # TODO: use xvfb in travis env 10 | # example: https://github.com/pytest-dev/pytest-qt/blob/master/.travis.yml 11 | @pytest.mark.skipif(is_travis_env, reason="travis env has no display") 12 | def test_table_meta(qtbot, app_mock): 13 | widget = TableMetaWidget(app_mock) 14 | qtbot.addWidget(widget) 15 | widget.title = '我喜欢的音乐' 16 | widget.subtitle = '嘿嘿' 17 | widget.creator = 'cosven' 18 | widget.updated_at = datetime.now() 19 | widget.desc = "
print('hello world')
"
20 | 


--------------------------------------------------------------------------------
/tests/test_bench.py:
--------------------------------------------------------------------------------
 1 | from feeluown.library import BriefSongModel
 2 | from feeluown.utils.utils import DedupList
 3 | 
 4 | 
 5 | def test_hash_model(benchmark, song):
 6 |     benchmark(hash, song)
 7 | 
 8 | 
 9 | def test_richcompare_model(benchmark, song):
10 |     benchmark(song.__eq__, song)
11 | 
12 | 
13 | def test_deduplist_addremove(benchmark):
14 |     def addremove():
15 |         song_list = DedupList()
16 |         num = 1000
17 |         for i in range(0, num):
18 |             song = BriefSongModel(source='xxxx', identifier=str(i))
19 |             if song not in song_list:
20 |                 song_list.append(song)
21 |         for i in range(num // 10):
22 |             song_list.pop(0)
23 |     benchmark(addremove)
24 | 


--------------------------------------------------------------------------------
/.vscode/launch.json:
--------------------------------------------------------------------------------
 1 | {
 2 |     // Use IntelliSense to learn about possible attributes.
 3 |     // Hover to view descriptions of existing attributes.
 4 |     // For more information, visit: https://go.microsoft.com/fwlink/?linkid=830387
 5 |     "version": "0.2.0",
 6 |     "configurations": [
 7 |         {
 8 |             "name": "Python: Module",
 9 |             "type": "python",
10 |             "request": "launch",
11 |             "module": "feeluown",
12 |             "args": [
13 |                 // we usually start a feeluown instance with server started 
14 |                 // for normal usage, so we use --no-server mode here
15 |                 "-ns",
16 |                 "-v"
17 |             ]
18 |         }
19 |     ]
20 | }


--------------------------------------------------------------------------------
/feeluown/server/handlers/help.py:
--------------------------------------------------------------------------------
 1 | from .cmd import Cmd
 2 | from .excs import HandlerException
 3 | from .base import AbstractHandler
 4 | 
 5 | 
 6 | class HelpHandler(AbstractHandler):
 7 |     cmds = 'help'
 8 | 
 9 |     def handle(self, cmd: Cmd):
10 |         return self.handle_help(*cmd.args)
11 | 
12 |     def handle_help(self, cmdname: str):
13 |         # pylint: disable=import-outside-toplevel
14 |         from feeluown.server.dslv2 import create_dsl_parser, get_subparser  # noqa
15 | 
16 |         parser = create_dsl_parser()
17 |         subparser = get_subparser(parser, cmdname)
18 |         if subparser is None:
19 |             raise HandlerException(f'help: no such cmd: {cmdname}')
20 |         return subparser.format_help()
21 | 


--------------------------------------------------------------------------------
/feeluown/server/pubsub/publishers.py:
--------------------------------------------------------------------------------
 1 | from functools import partial
 2 | 
 3 | from .gateway import Gateway
 4 | 
 5 | 
 6 | class LiveLyricPublisher:
 7 |     topic = 'live_lyric'
 8 | 
 9 |     def __init__(self, gateway: Gateway):
10 |         self.gateway = gateway
11 |         gateway.add_topic(self.topic)
12 | 
13 |     def publish(self, sentence):
14 |         self.gateway.publish(sentence + '\n', self.topic)
15 | 
16 | 
17 | class SignalPublisher:
18 |     def __init__(self, gateway: Gateway):
19 |         self.gateway = gateway
20 | 
21 |     def on_emitted(self, name):
22 |         return partial(self.publish, name)
23 | 
24 |     def publish(self, name, *args):
25 |         self.gateway.publish(list(args), name, need_serialize=True)
26 | 


--------------------------------------------------------------------------------
/tests/app/test_app.py:
--------------------------------------------------------------------------------
 1 | from unittest import skip
 2 | 
 3 | import pytest
 4 | 
 5 | from feeluown.app import create_app, AppMode
 6 | 
 7 | 
 8 | @skip("No easy way to simulate QEventLoop.")
 9 | def test_create_gui_app():
10 |     pass
11 | 
12 | 
13 | @pytest.mark.asyncio
14 | async def test_create_server_app(args, config, noharm):
15 |     config.MODE = AppMode.server
16 |     app = create_app(args, config=config)
17 |     assert app.has_server and not app.has_gui
18 | 
19 | 
20 | @pytest.mark.asyncio
21 | async def test_create_cli_app(args_test, config,
22 |                               noharm):
23 |     config.MODE = AppMode.cli
24 |     app = create_app(args_test, config=config)
25 |     assert not app.has_server and not app.has_gui
26 | 


--------------------------------------------------------------------------------
/feeluown/gui/widgets/__init__.py:
--------------------------------------------------------------------------------
 1 | from .textbtn import TextButton  # noqa
 2 | from .login import CookiesLoginDialog  # noqa
 3 | from .selfpaint_btn import (  # noqa
 4 |     SelfPaintAbstractSquareButton,
 5 |     RecentlyPlayedButton,
 6 |     HomeButton,
 7 |     LeftArrowButton,
 8 |     RightArrowButton,
 9 |     SearchSwitchButton,
10 |     SettingsButton,
11 |     PlusButton,
12 |     TriagleButton,
13 |     DiscoveryButton,
14 |     SelfPaintAbstractIconTextButton,
15 |     CalendarButton,
16 |     RankButton,
17 |     StarButton,
18 |     PlayPauseButton,
19 |     PlayNextButton,
20 |     PlayPreviousButton,
21 |     PlayButton,
22 |     MVButton,
23 |     VolumeButton,
24 |     HotButton,
25 |     EmojiButton,
26 |     AIButton,
27 | )
28 | 


--------------------------------------------------------------------------------
/feeluown/server/__init__.py:
--------------------------------------------------------------------------------
 1 | from feeluown.server.excs import FuoSyntaxError
 2 | from feeluown.server.data_structure import Request, Response
 3 | from feeluown.server.dslv1.lexer import Lexer
 4 | from feeluown.server.dslv1.parser import Parser
 5 | from .handlers import handle_request
 6 | from .server import FuoServer
 7 | from .protocol import FuoServerProtocol
 8 | from .protocol import ProtocolType
 9 | 
10 | 
11 | __all__ = (
12 |     'FuoServer',
13 |     'FuoServerProtocol',
14 | 
15 |     # exceptions
16 |     'FuoSyntaxError',
17 | 
18 |     # data structure
19 |     'Request',
20 |     'Response',
21 | 
22 |     # For compatibility.
23 |     'Lexer',
24 |     'Parser',
25 | 
26 |     'ProtocolType',
27 |     'handle_request',
28 | )
29 | 


--------------------------------------------------------------------------------
/feeluown/serializers/json_.py:
--------------------------------------------------------------------------------
 1 | import json
 2 | from .python import PythonSerializer
 3 | 
 4 | 
 5 | class JsonSerializer(PythonSerializer):
 6 | 
 7 |     def __init__(self, **options):
 8 |         dump_options_keys = ['skipkeys', 'ensure_ascii', 'check_circular',
 9 |                              'allow_nan', 'cls', 'indent', 'separators',
10 |                              'default', 'sort_keys', ]
11 |         self.dump_options = {}
12 |         for key in dump_options_keys:
13 |             if key in options:
14 |                 self.dump_options[key] = options.pop(key)
15 |         super().__init__(**options)
16 | 
17 |     def serialize(self, obj):
18 |         dict_ = super().serialize(obj)
19 |         return json.dumps(dict_, **self.dump_options)
20 | 


--------------------------------------------------------------------------------
/tests/server/handlers/test_handle.py:
--------------------------------------------------------------------------------
 1 | import json
 2 | 
 3 | import pytest
 4 | 
 5 | from feeluown.server import handle_request, Request
 6 | from feeluown.server.handlers.search import SearchHandler
 7 | 
 8 | 
 9 | @pytest.fixture
10 | def req():
11 |     return Request(
12 |         cmd='search',
13 |         cmd_args=['zjl'],
14 |         cmd_options={'type': ['song']},
15 |         options={'format': 'json'}
16 |     )
17 | 
18 | 
19 | @pytest.mark.asyncio
20 | async def test_handle_request(req, app_mock, mocker):
21 |     result = []
22 |     mocker.patch.object(SearchHandler, 'search', return_value=result)
23 |     resp = await handle_request(req, app_mock, mocker.Mock())
24 |     assert resp.code == 'OK'
25 |     assert json.loads(resp.text) == result
26 | 


--------------------------------------------------------------------------------
/feeluown/gui/widgets/my_music.py:
--------------------------------------------------------------------------------
 1 | import logging
 2 | 
 3 | from PyQt6.QtCore import Qt
 4 | from .textlist import TextlistModel, TextlistView
 5 | 
 6 | 
 7 | logger = logging.getLogger(__name__)
 8 | 
 9 | 
10 | class MyMusicModel(TextlistModel):
11 |     def data(self, index, role=Qt.ItemDataRole.DisplayRole):
12 |         row = index.row()
13 |         item = self._items[row]
14 |         if role == Qt.ItemDataRole.DisplayRole:
15 |             return item.text
16 |         return super().data(index, role)
17 | 
18 | 
19 | class MyMusicView(TextlistView):
20 |     def __init__(self, parent):
21 |         super().__init__(parent)
22 |         self.clicked.connect(
23 |             lambda index: index.data(role=Qt.ItemDataRole.UserRole).clicked.emit()
24 |         )
25 | 


--------------------------------------------------------------------------------
/docs/Makefile:
--------------------------------------------------------------------------------
 1 | # Minimal makefile for Sphinx documentation
 2 | #
 3 | 
 4 | # You can set these variables from the command line.
 5 | SPHINXOPTS    =
 6 | SPHINXBUILD   = sphinx-build
 7 | SPHINXPROJ    = feeluown
 8 | SOURCEDIR     = source
 9 | BUILDDIR      = build
10 | 
11 | # Put it first so that "make" without argument is like "make help".
12 | help:
13 | 	@$(SPHINXBUILD) -M help "$(SOURCEDIR)" "$(BUILDDIR)" $(SPHINXOPTS) $(O)
14 | 
15 | .PHONY: help Makefile
16 | 
17 | # Catch-all target: route all unknown targets to Sphinx using the new
18 | # "make mode" option.  $(O) is meant as a shortcut for $(SPHINXOPTS).
19 | %: Makefile
20 | 	@$(SPHINXBUILD) -M $@ "$(SOURCEDIR)" "$(BUILDDIR)" $(SPHINXOPTS) $(O)
21 | 
22 | livehtml:
23 | 	sphinx-autobuild $(SOURCEDIR) $(BUILDDIR)
24 | 


--------------------------------------------------------------------------------
/feeluown/gui/page_containers/scroll_area.py:
--------------------------------------------------------------------------------
 1 | import sys
 2 | 
 3 | from PyQt6.QtCore import Qt
 4 | from PyQt6.QtWidgets import QScrollArea, QFrame
 5 | 
 6 | from feeluown.gui.helpers import BgTransparentMixin
 7 | 
 8 | 
 9 | class ScrollArea(QScrollArea, BgTransparentMixin):
10 |     """
11 |     ScrollArea is designed to be used as a container for page_body.
12 |     Be careful when you use it as a container for other widgets.
13 |     """
14 | 
15 |     def __init__(self, parent=None):
16 |         super().__init__(parent=parent)
17 | 
18 |         self.setWidgetResizable(True)
19 |         self.setFrameShape(QFrame.Shape.NoFrame)
20 | 
21 |         if sys.platform.lower() != "darwin":
22 |             self.setVerticalScrollBarPolicy(Qt.ScrollBarPolicy.ScrollBarAlwaysOff)
23 | 


--------------------------------------------------------------------------------
/feeluown/gui/widgets/header.py:
--------------------------------------------------------------------------------
 1 | from PyQt6.QtGui import QFont
 2 | from PyQt6.QtWidgets import QLabel
 3 | 
 4 | 
 5 | class BaseHeader(QLabel):
 6 |     def __init__(self, *args, font_size=13, **kwargs):
 7 |         super().__init__(*args, **kwargs)
 8 | 
 9 |         font = self.font()
10 |         font.setPixelSize(font_size)
11 |         self.setFont(font)
12 | 
13 | 
14 | class LargeHeader(BaseHeader):
15 |     def __init__(self, *args, **kwargs):
16 |         super().__init__(*args, font_size=20, **kwargs)
17 | 
18 |         font = self.font()
19 |         font.setWeight(QFont.Weight.DemiBold)
20 |         self.setFont(font)
21 | 
22 | 
23 | class MidHeader(BaseHeader):
24 |     def __init__(self, *args, **kwargs):
25 |         super().__init__(*args, font_size=16, **kwargs)
26 | 


--------------------------------------------------------------------------------
/.github/ISSUE_TEMPLATE/bug_report.md:
--------------------------------------------------------------------------------
 1 | ---
 2 | name: Bug report
 3 | about: Create a report to help us improve
 4 | title: ''
 5 | labels: ''
 6 | assignees: ''
 7 | 
 8 | ---
 9 | 
10 | **Describe the bug**
11 | A clear and concise description of what the bug is.
12 | 
13 | **To Reproduce**
14 | Steps to reproduce the behavior:
15 | 1. Go to '...'
16 | 2. Click on '....'
17 | 3. Scroll down to '....'
18 | 4. See error
19 | 
20 | **Expected behavior**
21 | A clear and concise description of what you expected to happen.
22 | 
23 | **Screenshots**
24 | If applicable, add screenshots to help explain your problem.
25 | 
26 | **Desktop:**
27 |  - OS: [e.g. Ubuntu 18.04]
28 |  - Version: [e.g. feeluown 3.3.6]
29 | 
30 | **Additional context**
31 | Add any other context about the problem here.
32 | 


--------------------------------------------------------------------------------
/feeluown/gui/pages/recently_played.py:
--------------------------------------------------------------------------------
 1 | from feeluown.utils.aio import run_afn
 2 | from feeluown.utils.reader import create_reader
 3 | from feeluown.gui.page_containers.table import Renderer
 4 | 
 5 | 
 6 | async def render(req, **kwargs):
 7 |     app = req.ctx["app"]
 8 |     right_panel = app.ui.right_panel
 9 |     right_panel.set_body(right_panel.table_container)
10 |     run_afn(app.ui.table_container.set_renderer, PlayerPlaylistRenderer())
11 | 
12 | 
13 | class PlayerPlaylistRenderer(Renderer):
14 |     async def render(self):
15 |         self.meta_widget.title = "最近播放"
16 |         self.meta_widget.show()
17 |         songs = self._app.recently_played.list_songs()
18 |         reader = create_reader(songs)
19 |         self.show_songs(reader)
20 |         self.toolbar.hide()
21 | 


--------------------------------------------------------------------------------
/docs/source/index.rst:
--------------------------------------------------------------------------------
 1 | FeelUOwn - feel your own
 2 | ========================
 3 | 
 4 | FeelUOwn 是一个用户友好、可玩性强的播放器
 5 | 
 6 | .. image:: https://user-images.githubusercontent.com/4962134/43506587-1c56c960-959d-11e8-964f-016159cbeeb9.png
 7 | 
 8 | 它主要有以下特性:
 9 | 
10 | - 安装简单,使用方便,新手友好
11 | - 默认提供国内各音乐平台插件(网易云、虾米、QQ)
12 | - 与 tmux,Emacs,Vim 等工具可以方便集成
13 | - 核心模块有较好文档和测试覆盖
14 | 
15 | .. toctree::
16 |    :maxdepth: 2
17 |    :caption: 用户指南
18 | 
19 |    quickstart
20 |    features
21 |    fuorc
22 |    roadmap
23 |    faq
24 | 
25 | 
26 | .. toctree::
27 |    :maxdepth: 2
28 |    :caption: 开发者指南
29 | 
30 |    dev_quickstart
31 |    arch
32 |    api
33 |    media_assets_management/index
34 |    protocol
35 |    glossary
36 |    research
37 |    philosophy
38 |    coding_style
39 |    contributing
40 | 


--------------------------------------------------------------------------------
/feeluown/gui/uimodels/playlist.py:
--------------------------------------------------------------------------------
 1 | """
 2 | 播放列表管理
 3 | ~~~~~~~~~~~~~
 4 | """
 5 | 
 6 | from feeluown.gui.widgets.playlists import PlaylistsModel
 7 | 
 8 | from feeluown.library import PlaylistModel
 9 | 
10 | 
11 | class PlaylistUiItem(PlaylistModel):
12 |     """
13 |     根据目前经验,播放列表的相关操作最基本的就是几个:
14 | 
15 |     * 创建、删除
16 |     * 添加、移除歌曲
17 |     * 重命名
18 |     * 点击展示这个歌单
19 | 
20 |     这些操作对各平台的播放列表、歌单来说,语义都是一致的,
21 |     所以 PlaylistUiItem 暂时不提供 clicked 等操作信号。
22 |     """
23 | 
24 | 
25 | class PlaylistUiManager:
26 |     def __init__(self, app):
27 |         self._app = app
28 |         self.model = PlaylistsModel(app)
29 | 
30 |     def add(self, playlist, is_fav=False):
31 |         self.model.add(playlist, is_fav=is_fav)
32 | 
33 |     def clear(self):
34 |         self.model.clear()
35 | 


--------------------------------------------------------------------------------
/feeluown/gui/widgets/statusline_items/notify.py:
--------------------------------------------------------------------------------
 1 | from PyQt6.QtCore import Qt, QRectF, QPoint
 2 | from PyQt6.QtGui import QTextOption
 3 | from PyQt6.QtWidgets import QToolTip
 4 | 
 5 | from feeluown.gui.widgets.statusline import StatuslineLabel
 6 | 
 7 | 
 8 | class NotifyStatus(StatuslineLabel):
 9 |     def __init__(self, app, parent=None):
10 |         super().__init__(app, parent)
11 |         self._app = app
12 | 
13 |     def drawInner(self, painter):
14 |         inner_rect = QRectF(0, 0, self._inner_width, self._inner_height)
15 |         painter.drawText(inner_rect, "✉", QTextOption(Qt.AlignmentFlag.AlignCenter))
16 | 
17 |     def show_msg(self, text, timeout=1500):
18 |         QToolTip.showText(
19 |             self.mapToGlobal(QPoint(0, 0)), text, self, self.rect(), timeout
20 |         )
21 | 


--------------------------------------------------------------------------------
/tests/helpers.py:
--------------------------------------------------------------------------------
 1 | import os
 2 | import sys
 3 | import asyncio
 4 | 
 5 | 
 6 | is_travis_env = os.environ.get('TEST_ENV') == 'travis'
 7 | 
 8 | cannot_play_audio = is_travis_env
 9 | 
10 | # By set QT_QPA_PLATFORM=offscreen, qt widgets can run on various CI.
11 | cannot_run_qt_test = False
12 | is_macos = sys.platform == 'darwin'
13 | 
14 | 
15 | async def aio_waitfor_simple_tasks():
16 |     """
17 |     Many async test cases need to assert some internal tasks must be done,
18 |     but we have no way to confirm if it is done. In most cases, call asyncio.sleep
19 |     to let asyncio schedule the task is enough.
20 | 
21 |     Here, simple means that the task can be done as long as it is scheduled.
22 | 
23 |     HELP: find a more robust way to do this.
24 |     """
25 |     await asyncio.sleep(0.001)
26 | 


--------------------------------------------------------------------------------
/feeluown/library/flags.py:
--------------------------------------------------------------------------------
 1 | from enum import IntFlag
 2 | 
 3 | 
 4 | class Flags(IntFlag):
 5 |     """
 6 |     NOTE: the flag value maybe changed in the future
 7 |     """
 8 |     none = 0x00000000
 9 | 
10 |     get = 0x00000001
11 |     batch = 0x00000002
12 | 
13 |     # Reader
14 |     songs_rd = 0x00000010
15 |     albums_rd = 0x00000020
16 |     artists_rd = 0x00000040
17 |     playlists_rd = 0x00000080
18 | 
19 |     multi_quality = 0x0001000
20 |     similar = 0x00002000
21 |     hot_comments = 0x00008000
22 |     web_url = 0x00010000
23 |     model_v2 = 0x00004000
24 | 
25 |     current_user = 0x00100000
26 | 
27 |     # Impl: x_add_song(x, song)
28 |     add_song = 0x01000000
29 |     remove_song = 0x02000000
30 | 
31 |     # Flags for Song
32 |     lyric = 0x100000000000
33 |     mv = 0x200000000000
34 | 


--------------------------------------------------------------------------------
/feeluown/server/handlers/exec_.py:
--------------------------------------------------------------------------------
 1 | import io
 2 | import sys
 3 | import traceback
 4 | 
 5 | from feeluown.fuoexec import fuoexec
 6 | from .base import AbstractHandler
 7 | 
 8 | 
 9 | class ExecHandler(AbstractHandler):
10 |     cmds = 'exec'
11 | 
12 |     def handle(self, cmd):
13 |         code = cmd.args[0]
14 |         output = io.StringIO()
15 |         sys.stderr = output
16 |         sys.stdout = output
17 |         try:
18 |             self.exec_(code)
19 |         except:  # noqa: E722
20 |             traceback.print_exc()
21 |         finally:
22 |             sys.stderr = sys.__stderr__
23 |             sys.stdout = sys.__stdout__
24 |         msg = output.getvalue()
25 |         return msg
26 | 
27 |     def exec_(self, code):
28 |         obj = compile(code, '', 'exec')
29 |         fuoexec(obj)
30 | 


--------------------------------------------------------------------------------
/tests/ai/test_prompt.py:
--------------------------------------------------------------------------------
 1 | import pytest
 2 | 
 3 | from feeluown.collection import Collection
 4 | from feeluown.ai.prompt import generate_prompt_for_library
 5 | 
 6 | 
 7 | @pytest.fixture
 8 | def library_mock(song, song1, artist, album):
 9 |     library = Collection('/tmp/nonexist.txt')
10 |     for i in range(0, 10):
11 |         library.models.append(song)
12 |         library.models.append(song1)
13 |         library.models.append(artist)
14 |         library.models.append(album)
15 |     return library
16 | 
17 | 
18 | @pytest.mark.asyncio
19 | async def test_generate_prompt_for_library(library_mock):
20 |     prompt = await generate_prompt_for_library(library_mock)
21 |     assert 'hello world - mary' in prompt  # song
22 |     assert 'mary\n' in prompt  # artist
23 |     assert 'blue and green' in prompt  # album
24 | 


--------------------------------------------------------------------------------
/feeluown/gui/widgets/separator.py:
--------------------------------------------------------------------------------
 1 | from PyQt6.QtWidgets import QFrame
 2 | 
 3 | 
 4 | stylesheet = """
 5 | QFrame[frameShape="4"],
 6 | QFrame[frameShape="5"]
 7 | {{
 8 |     border: none;
 9 |     background: {};
10 | }}
11 | """
12 | 
13 | 
14 | class Separator(QFrame):
15 |     def __init__(self, app, orientation="horizontal"):
16 |         super().__init__(parent=None)
17 | 
18 |         self._app = app
19 | 
20 |         if orientation == "horizontal":
21 |             self.setFrameShape(QFrame.Shape.HLine)
22 |         else:
23 |             self.setFrameShape(QFrame.Shape.VLine)
24 | 
25 |         if self._app.theme_mgr.theme == "dark":
26 |             self.setStyleSheet(stylesheet.format("#232323"))
27 |             self.setMaximumHeight(1)
28 |         else:
29 |             self.setFrameShadow(QFrame.Shadow.Sunken)
30 | 


--------------------------------------------------------------------------------
/examples/research/bench_getattr.py:
--------------------------------------------------------------------------------
 1 | """
 2 | 测试通过 obj.attr 和 await async_run(lambda: obj.attr) 性能差别
 3 | 
 4 | 测试结果:十万次 obj.attr 花费时间是毫秒级,而后者是秒级(10 秒左右)。
 5 | """
 6 | 
 7 | 
 8 | import asyncio
 9 | import time
10 | 
11 | 
12 | class Obj:
13 |     def __init__(self):
14 |         self.attr = 1
15 | 
16 |     @property
17 |     def prop(self):
18 |         return 1
19 | 
20 | 
21 | async def f():
22 |     loop = asyncio.get_event_loop()
23 |     obj = Obj()
24 | 
25 |     t = time.time()
26 |     t_cpu = time.process_time()
27 | 
28 |     for _ in range(0, 100000):
29 |         await loop.run_in_executor(None, lambda: obj.attr)
30 | 
31 |     print(time.time() - t )
32 |     print(time.process_time() - t_cpu)
33 | 
34 | 
35 | 
36 | if __name__ == '__main__':
37 |     loop = asyncio.get_event_loop()
38 |     loop.run_until_complete(f())
39 | 


--------------------------------------------------------------------------------
/feeluown/utils/lang.py:
--------------------------------------------------------------------------------
 1 | def can_convert_chinese():
 2 |     try:
 3 |         import inlp.convert.chinese  # noqa
 4 |     except ImportError:
 5 |         return False
 6 |     return True
 7 | 
 8 | 
 9 | def convert_chinese(s: str, lang):
10 |     """Simple to traditional, or reverse.
11 | 
12 |     Please ensure can_convert_chinese is True before invoke this function.
13 | 
14 |     Note, this feature is available only if the `inlp` package is installed.
15 |     """
16 | 
17 |     import inlp.convert.chinese as cv  # noqa
18 | 
19 |     try:
20 |         # I don't know what 'auto' refers to.
21 |         assert lang in ['auto', 'cn', 'tc']
22 | 
23 |         if lang == 'cn':
24 |             return cv.t2s(s)
25 |         elif lang == 'tc':
26 |             return cv.s2t(s)
27 |         return s
28 |     except:  # noqa
29 |         return s
30 | 


--------------------------------------------------------------------------------
/tests/app/test_server_app.py:
--------------------------------------------------------------------------------
 1 | import pytest
 2 | 
 3 | from feeluown.app import AppMode
 4 | from feeluown.app.server_app import ServerApp
 5 | 
 6 | 
 7 | @pytest.mark.asyncio
 8 | async def test_server_app_initialize(signal_aio_support, args, config, mocker,
 9 |                                      noharm):
10 |     config.MODE = AppMode.server
11 | 
12 |     app = ServerApp(args, config)
13 | 
14 |     mock_connect_1 = mocker.patch.object(app.live_lyric.sentence_changed, 'connect')
15 |     mock_connect_2 = mocker.patch.object(app.player.position_changed, 'connect')
16 |     mock_connect_3 = mocker.patch.object(app.playlist.song_changed, 'connect')
17 | 
18 |     app.initialize()
19 | 
20 |     # live lyric should be initialized properly.
21 |     assert mock_connect_1.called
22 |     assert not mock_connect_2.called
23 |     assert mock_connect_3.called
24 | 


--------------------------------------------------------------------------------
/.pylintrc:
--------------------------------------------------------------------------------
 1 | [MASTER]
 2 | disable=
 3 |     unknown-option-value,  # some options are only valid in latest pylint
 4 | 
 5 |     missing-docstring,
 6 |     too-few-public-methods,
 7 |     no-name-in-module,  # seems not working for PyQt6
 8 |     too-many-instance-attributes,  # qt widget class can has many attributes
 9 |     unnecessary-lambda,  # pylint is a little bit silly
10 |     unused-argument,  # not so critical
11 |     fixme,
12 |     invalid-name,
13 |     logging-fstring-interpolation,
14 |     bare-except,
15 |     import-outside-toplevel,  # too verbose
16 | 
17 |     # Let flake8 check import-related code.
18 |     wrong-import-position,
19 |     unused-import,
20 |     cyclic-import,
21 |     broad-exception-caught,
22 | 
23 |     # Enable following in the future.
24 | 
25 |     no-else-raise,
26 |     bad-mcs-classmethod-argument,
27 |     no-else-return
28 | 


--------------------------------------------------------------------------------
/feeluown/library/model_state.py:
--------------------------------------------------------------------------------
 1 | from enum import IntFlag
 2 | 
 3 | 
 4 | class ModelState(IntFlag):
 5 |     none = 0x00000000
 6 | 
 7 |     #: the model is created manually
 8 |     # the model identifier may not exist
 9 |     # the model fields values are not accurate
10 |     artificial = 0x00000001
11 | 
12 |     #: the model identifier existence is proved
13 |     # the model fields values are not accurate
14 |     exists = 0x00000002
15 | 
16 |     #: the model identifier does not exist
17 |     not_exists = 0x00000004
18 | 
19 |     #: the model identifier existence is proved and fields value are accurate
20 |     # the model is a brief model
21 |     cant_upgrade = exists | 0x00000010
22 | 
23 |     #: the model identifier existence is proved and fields value are accurate
24 |     # the model is a normal model instead of a brief model
25 |     upgraded = exists | 0x000000020
26 | 


--------------------------------------------------------------------------------
/docs/source/media_assets_management/provider.rst:
--------------------------------------------------------------------------------
 1 | 资源提供方
 2 | =====================
 3 | 
 4 | 歌曲等音乐资源都来自于某一个提供方。比如,我们认为本地音乐的提供方是本地,
 5 | 网易云音乐资源的提供方是网易,等等。对应到程序设计上,每个提供方都对应一个 provider 实例。
 6 | provider 是我们访问具体一个音乐平台资源音乐的入口。
 7 | 
 8 | 在 feeluown 生态中,每个音乐资源提供方都对应着一个插件,我们现在有 feeluown-local/feeluown-netease
 9 | 等许多插件,这些插件在启动时,会注册一个 provider 实例到 feeluown 的音乐库模块上。
10 | 注册完成之后,音乐库和 feeluown 其它模块就能访问到这个提供方的资源
11 | 
12 | 举个栗子,feeluown-local 插件在启动时就创建了一个 *identifier* 为 ``local`` 的 provider 实例,
13 | 并将它注册到音乐库中,这样,当我们访问音乐库资源时,就能访问到本地音乐资源。
14 | 
15 | 详细信息请参考 :doc:`provider`。
16 | 
17 | 定义一个资源提供方
18 | ~~~~~~~~~~~~~~~~~~~~~~~~~~~~
19 | 
20 | .. autoclass:: feeluown.library.AbstractProvider
21 |    :members:
22 | 
23 | .. autoclass:: feeluown.library.Provider
24 |    :members:
25 | 
26 | 协议
27 | ~~~~~~~~~~~~~~~~~~~~~~~~~~~~
28 | 
29 | .. automodule:: feeluown.library.provider_protocol
30 |    :members:
31 | 
32 | 


--------------------------------------------------------------------------------
/feeluown/gui/consts.py:
--------------------------------------------------------------------------------
 1 | import sys
 2 | 
 3 | if sys.platform == "win32":
 4 |     # By default, it uses SimSun(宋体) on windows, which is a little ugly.
 5 |     # "Segoe UI Symbol" is used to render charactor symbols.
 6 |     # "Microsoft Yahei" is used to render chinese (and english).
 7 |     # Choose a default sans-serif font when the first two fonts do not work,
 8 |     FontFamilies = ["Segoe UI Symbol", "Microsoft YaHei", "sans-serif"]
 9 | elif sys.platform == "darwin":
10 |     FontFamilies = ["arial", "Hiragino Sans GB", "sans-serif"]
11 | else:
12 |     FontFamilies = [
13 |         "Helvetica Neue",
14 |         "Helvetica",
15 |         "Arial",
16 |         "Microsoft Yahei",
17 |         "Hiragino Sans GB",
18 |         "Heiti SC",
19 |         "WenQuanYi Micro Hei",
20 |         "sans-serif",
21 |     ]
22 | 
23 | # The width of ScrollBar on macOS is about 10.
24 | ScrollBarWidth = 10
25 | 


--------------------------------------------------------------------------------
/tests/gui/uimain/test_uimain.py:
--------------------------------------------------------------------------------
 1 | from feeluown.media import Media
 2 | from feeluown.player import PlaybackMode
 3 | from feeluown.gui.uimain.player_bar import PlayerControlPanel
 4 | from feeluown.gui.uimain.playlist_overlay import PlaylistOverlay
 5 | 
 6 | 
 7 | def test_show_bitrate(qtbot, app_mock):
 8 |     app_mock.player.current_media = Media('http://', bitrate=100)
 9 |     w = PlayerControlPanel(app_mock)
10 |     qtbot.addWidget(w)
11 |     metadata = {'title': 'xx'}
12 |     w.song_source_label.on_metadata_changed(metadata)
13 |     assert '100kbps' in w.song_source_label.text()
14 | 
15 | 
16 | def test_playlist_overlay(qtbot, app_mock):
17 |     app_mock.playlist.playback_mode = PlaybackMode.one_loop
18 |     app_mock.playlist.list.return_value = []
19 |     w = PlaylistOverlay(app_mock)
20 |     qtbot.addWidget(w)
21 |     w.show()
22 |     # assert no error.
23 |     w.show_tab(0)
24 | 


--------------------------------------------------------------------------------
/feeluown/gui/assets/themes/dark.qss:
--------------------------------------------------------------------------------
 1 | 
 2 | /* It seems old version Qt can't parse the following code
 3 |  * stop: 0 palette(window), stop: 1 palette(base),
 4 |  * we hardcode the color here.
 5 |  */
 6 | TopPanel {
 7 |     background-color: qlineargradient(
 8 |         x1: 0, y1: 0, x2: 0, y2: 1,
 9 |         stop: 0 #323232, stop: 1 #1e1e1e
10 |     );
11 | }
12 | 
13 | Separator {
14 |     background-color: #444;
15 | }
16 | 
17 | TextButton {
18 |     border: 1px solid #444;
19 | }
20 | 
21 | TextButton:pressed, ToolbarButton:pressed {
22 |     background-color: #3e3e3e;
23 | }
24 | 
25 | #like_btn {
26 |     border-image: url(icons:like_dark.png);
27 | }
28 | #like_btn:checked {
29 |     border-image: url(icons:like_checked_dark.png);
30 | }
31 | #download_btn {
32 |     border-image: url(icons:download_dark.png);
33 | }
34 | #download_btn:checked {
35 |     border-image: url(icons:already_downloaded_dark.png);
36 | }
37 | 


--------------------------------------------------------------------------------
/.travis.yml:
--------------------------------------------------------------------------------
 1 | dist: xenial
 2 | services:
 3 |   - xvfb
 4 | sudo: required
 5 | 
 6 | language: python
 7 | python:
 8 |     - 3.6
 9 |     - 3.7
10 |     - 3.8
11 | 
12 | env:
13 |     TEST_ENV: travis
14 | 
15 | install:
16 |     - sudo apt-get install libmpv1 -y --force-yes
17 |     # https://github.com/pytest-dev/pytest-qt/issues/266
18 |     - sudo apt-get install -y libdbus-1-3 libxkbcommon-x11-0
19 |     # travis will auto create a virtualenv, as a result,
20 |     # we can't import from system site-package
21 |     # - sudo apt-get install python3-dbus python3-dbus.mainloop.pyqt5 -y
22 |     #
23 |     # TODO: think about a way to test mpris2 in travis environment
24 |     - pip install pyqt5==5.14.0 pyopengl
25 |     - pip install coveralls
26 |     - pip install sphinx_rtd_theme
27 |     - pip install -e . --group dev
28 | 
29 | script:
30 |     - make test
31 |     - make integration_test
32 | 
33 | after_success:
34 |   - coveralls
35 | 


--------------------------------------------------------------------------------
/tests/gui/test_browser.py:
--------------------------------------------------------------------------------
 1 | from unittest import mock
 2 | from feeluown.gui.browser import Browser
 3 | 
 4 | 
 5 | def test_browser_basic(app_mock, mocker, album):
 6 |     mock_r_search = mocker.patch(
 7 |         'feeluown.gui.pages.search.render', new=mock.MagicMock)
 8 |     mock_r_model = mocker.patch(
 9 |         'feeluown.gui.pages.model.render', new=mock.MagicMock)
10 |     mocker.patch('feeluown.gui.browser.resolve', return_value=album)
11 | 
12 |     browser = Browser(app_mock)
13 |     browser.initialize()
14 |     browser.goto(page='/search')
15 |     # renderer should be called once
16 |     mock_r_search.assert_called
17 | 
18 |     browser.goto(model=album)
19 |     mock_r_model.assert_called
20 |     # history should be saved
21 |     assert browser.can_back
22 |     browser.back()
23 |     assert browser.current_page == '/search'
24 | 
25 |     browser.forward()
26 |     assert browser.current_page.startswith('/models/')
27 | 


--------------------------------------------------------------------------------
/feeluown/library/collection.py:
--------------------------------------------------------------------------------
 1 | from enum import Enum
 2 | from dataclasses import dataclass
 3 | from typing import List
 4 | 
 5 | from .models import BaseModel
 6 | 
 7 | 
 8 | class CollectionType(Enum):
 9 |     """Collection type enumeration"""
10 | 
11 |     # These two values are only used in local collection.
12 |     sys_library = 16
13 |     sys_pool = 13
14 | 
15 |     only_songs = 1
16 |     only_artists = 2
17 |     only_albums = 3
18 |     only_playlists = 4
19 |     only_lyrics = 5
20 |     only_videos = 6
21 | 
22 |     only_users = 17
23 |     only_comments = 18
24 | 
25 |     mixed = 32
26 | 
27 | 
28 | @dataclass
29 | class Collection:
30 |     """
31 |     Differences between a collection and a playlist
32 |     - A collection has no identifier in general.
33 |     - A collection may have songs, albums and artists.
34 |     """
35 |     name: str
36 |     type_: CollectionType
37 |     models: List[BaseModel]
38 |     description: str = ''
39 | 


--------------------------------------------------------------------------------
/docs/source/glossary.rst:
--------------------------------------------------------------------------------
 1 | 相关专业术语
 2 | ============
 3 | 
 4 | 下面是 feeluown 中项目使用的一些专业名词,这些专业名词会体现在文档、代码注释、
 5 | 变量命名等各方面。
 6 | 
 7 | .. glossary::
 8 | 
 9 |    library
10 |      音乐库
11 | 
12 |    provider
13 |      资源提供方,提供媒体媒体资源的平台。
14 | 
15 |      比如 YouTube、网易云音乐、虾米等。
16 | 
17 |    source
18 |      资源提供方标识。
19 | 
20 |      在 feeluown 中,歌曲、歌手等资源都有一个 source 属性来标识该资源的来源。
21 | 
22 |    media
23 |      媒体实体资源
24 | 
25 |      Media 关注的点是“可播放”的实体资源。在 feeluown 中,Media 有两种类型,
26 |      Audio 和 Video (以后可能会新增 Image),它用来保存资源的元信息,
27 |      对于 Audio 来说,有 bitrate, channel count 等元信息。对于 Video
28 |      来说,有 framerate, codec 等信息。
29 | 
30 |      和 Song 不一样,Song 逐重强调一首歌的元信息部分,比如歌曲名称、歌手等。
31 |      理论上,我们可以从 Media(Audio) 中解析出部分 Song 信息。
32 | 
33 |    FeelUOwn
34 |      项目名,泛指 FeelUOwn 整个项目。从 GitHub repo 角度看,它指 github.com/feeluown
35 |      目录下所有的项目。
36 | 
37 |    feeluown
38 |      包名(Python 包),程序名。从 GitHub repo 角度看,
39 |      它特指 github.com/feeluown/feeluown 这个项目。
40 | 
41 |    fuo
42 |      程序名
43 | 


--------------------------------------------------------------------------------
/tests/gui/widgets/test_song_minicard_list.py:
--------------------------------------------------------------------------------
 1 | import pytest
 2 | from PyQt6.QtCore import QModelIndex
 3 | 
 4 | from feeluown.utils import aio
 5 | from feeluown.utils.reader import create_reader
 6 | from feeluown.gui.widgets.song_minicard_list import SongMiniCardListModel
 7 | 
 8 | 
 9 | @pytest.mark.asyncio
10 | async def test_song_mini_card_list_model_remove_pixmap(qtbot, song):
11 |     async def fetch_cover(*_): return b'image content'
12 |     model = SongMiniCardListModel(create_reader([song]), fetch_cover)
13 |     assert model.rowCount() == 0
14 |     model.fetchMore(QModelIndex())
15 |     await aio.sleep(0.1)
16 |     assert model.rowCount() == 1
17 |     model.get_pixmap_unblocking(song)
18 |     await aio.sleep(0.1)
19 |     assert len(model.pixmaps) == 1
20 |     with qtbot.waitSignal(model.rowsAboutToBeRemoved):
21 |         model.beginRemoveRows(QModelIndex(), 0, 0)
22 |         model._items.pop(0)
23 |         model.endRemoveRows()
24 |     assert len(model.pixmaps) == 0
25 | 


--------------------------------------------------------------------------------
/feeluown/server/excs.py:
--------------------------------------------------------------------------------
 1 | class FuoProtocolError(Exception):
 2 |     pass
 3 | 
 4 | 
 5 | class FuoSyntaxError(FuoProtocolError):
 6 |     """fuo syntax error
 7 | 
 8 |     >>> e = FuoSyntaxError('invalid syntax', column=9, text="play 'fuo")
 9 |     >>> print(e.human_readabe_msg)
10 |     invalid syntax
11 |       play 'fuo
12 |                ^
13 |     """
14 |     def __init__(self, *args, column=None, text=None):
15 |         super().__init__(*args)
16 |         # both column and text should be None or neither is None
17 |         assert not (column is None) ^ (text is None)
18 | 
19 |         self.column = column
20 |         self.text = text
21 | 
22 |     @property
23 |     def human_readabe_msg(self):
24 |         if self.column is not None:
25 |             tpl = '{msg}\n  {text}\n  {arrow}'
26 |             msg = tpl.format(text=self.text,
27 |                              msg=str(self),
28 |                              arrow=(self.column) * ' ' + '^')
29 |             return msg
30 |         return str(self)
31 | 


--------------------------------------------------------------------------------
/feeluown/gui/uimodels/my_music.py:
--------------------------------------------------------------------------------
 1 | from feeluown.utils.dispatch import Signal
 2 | from feeluown.gui.widgets.my_music import MyMusicModel
 3 | 
 4 | 
 5 | class MyMusicItem:
 6 |     def __init__(self, text):
 7 |         self.text = text
 8 |         self.clicked = Signal()
 9 | 
10 | 
11 | class MyMusicUiManager:
12 |     """
13 | 
14 |     .. note::
15 | 
16 |         目前,我们用数组的数据结构来保存 items,只提供 add_item 和 clear 方法。
17 |         我们希望,MyMusic 中的 items 应该和 provider 保持关联。provider 是 MyMusic
18 |         的上下文。
19 | 
20 |         而 Provider 是比较上层的对象,我们会提供 get_item 这种比较精细的控制方法。
21 |     """
22 | 
23 |     def __init__(self, app):
24 |         self._app = app
25 |         self._items = []
26 |         self.model = MyMusicModel(app)
27 | 
28 |     @classmethod
29 |     def create_item(cls, text):
30 |         return MyMusicItem(text)
31 | 
32 |     def add_item(self, item):
33 |         self.model.add(item)
34 |         self._items.append(item)
35 | 
36 |     def clear(self):
37 |         self._items.clear()
38 |         self.model.clear()
39 | 


--------------------------------------------------------------------------------
/docs/source/coding_style.rst:
--------------------------------------------------------------------------------
 1 | 代码风格
 2 | ================
 3 | 
 4 | 
 5 | 注释
 6 | -------
 7 | 
 8 | - 注释统一用英文(老的中文注释应该逐渐更改)
 9 | - docstring 使用 `Sphinx docstring format`_
10 | - FIXME, TODO, and HELP
11 |   - FIXME: 开发者明确知道某个地方代码不优雅
12 |   - HELP: 开发者写了一段自己不太理解,但是可以正确工作的代码
13 |   - TODO: 一些可以以后完成的事情
14 |   - 暂时不推荐使用 NOTE 标记
15 | 
16 | 命名
17 | -------
18 | 
19 | 
20 | 测试
21 | --------
22 | 
23 | - feeluown 包相关代码都应该添加相应测试
24 | 
25 | 错误处理
26 | ------------
27 | 
28 | - Qt 中同步调用资源提供方接口时,都应该处理 Exception 异常,否则应用可能会 crash
29 | 
30 | 日志和提示
31 | -----------
32 | 
33 | 特殊风格
34 | -----------
35 | 
36 | - 标记了 alpha 的函数和类,它们的设计都是不确定的,外部应该尽少依赖
37 | 
38 | 类
39 | -----------
40 | 
41 | - Qt Widget 的子类的 UI 相关设置初始化应该放在 ``_setup_ui`` 函数中
42 | - 信号的 slot 方法应该设为 protected 方法
43 | - 类的 public 方法放在类的前面,protected 和 private 方法放在类的最后面,
44 |   ``_setup_ui`` 函数除外
45 | - QWidget 子类最好不要有 async 方法,因为目前无法很好的为它编写相关单元测试
46 | 
47 | 
48 | 
49 | .. _Sphinx docstring format: https://sphinx-rtd-tutorial.readthedocs.io/en/latest/docstrings.html#the-sphinx-docstring-format
50 | 


--------------------------------------------------------------------------------
/feeluown/gui/pages/search.py:
--------------------------------------------------------------------------------
 1 | from feeluown.library import SearchType
 2 | from feeluown.utils.router import Request
 3 | from feeluown.app.gui_app import GuiApp
 4 | from feeluown.gui.components.search import SearchResultView
 5 | 
 6 | 
 7 | def get_source_in(req: Request):
 8 |     source_in = req.query.get("source_in", None)
 9 |     if source_in is not None:
10 |         source_in = source_in.split(",")
11 |     else:
12 |         source_in = None
13 |     return source_in
14 | 
15 | 
16 | async def render(req: Request, **kwargs):
17 |     """/search handler
18 | 
19 |     :type app: feeluown.app.App
20 |     """
21 |     # pylint: disable=too-many-locals,too-many-branches
22 |     q = req.query.get("q", "")
23 |     if not q:
24 |         return
25 | 
26 |     app: "GuiApp" = req.ctx["app"]
27 |     source_in = get_source_in(req)
28 |     search_type = SearchType(req.query.get("type", SearchType.so.value))
29 | 
30 |     view = SearchResultView(app)
31 |     app.ui.right_panel.set_body(view)
32 |     await view.search_and_render(q, search_type, source_in)
33 | 


--------------------------------------------------------------------------------
/tests/server/handlers/test_handlers.py:
--------------------------------------------------------------------------------
 1 | from unittest.mock import call
 2 | 
 3 | import pytest
 4 | 
 5 | from feeluown.server.handlers.sub import SubHandler
 6 | from feeluown.server.handlers.help import HelpHandler
 7 | 
 8 | 
 9 | @pytest.fixture
10 | def session_mock(mocker):
11 |     return mocker.Mock()
12 | 
13 | 
14 | def test_handle_sub(app_mock, session_mock):
15 |     handler = SubHandler(app_mock, session_mock)
16 |     mock_link = app_mock.pubsub_gateway.link
17 |     app_mock.pubsub_gateway.topics = [
18 |         'player.seeked',
19 |         'player.media_changed'
20 |     ]
21 |     handler.handle_sub('player.*')
22 | 
23 |     mock_link.assert_has_calls([
24 |         call(topic, session_mock)
25 |         for topic in app_mock.pubsub_gateway.topics
26 |     ])
27 | 
28 | 
29 | def test_handle_help(app_mock, session_mock):
30 |     handler = HelpHandler(app_mock, session_mock)
31 |     # No error should occur.
32 |     handler.handle_help('set')
33 |     handler.handle_help('sub')
34 | 
35 |     result = handler.handle_help('status')
36 |     assert 'usage' in result
37 | 


--------------------------------------------------------------------------------
/.gitignore:
--------------------------------------------------------------------------------
 1 | # PyCharm
 2 | # http://www.jetbrains.com/pycharm/webhelp/project.html
 3 | .idea
 4 | .iml
 5 | .settings
 6 | 
 7 | # pyinstaller
 8 | /FeelUOwn.spec
 9 | /FeelUOwnX.spec
10 | 
11 | *.mp3
12 | 
13 | *.pyc
14 | *.swp
15 | *.swo
16 | *.whl
17 | 
18 | *.desktop
19 | *.md~
20 | *.py~
21 | 
22 | sphinx_doc/build
23 | test.py
24 | user.json
25 | 
26 | *.bak
27 | *.vim-bookmarks
28 | 
29 | \#*
30 | 
31 | __pycache__
32 | .cache
33 | .ipynb_checkpoints
34 | *.DS_Store
35 | *.un~
36 | *.desktop
37 | app.command
38 | *.log
39 | 
40 | cookie*
41 | .coverage
42 | 
43 | # user data
44 | data/*.json
45 | cache/*.json
46 | 
47 | # test image
48 | # feeluown/*.png
49 | # feeluown/*.jpg
50 | # feeluown.gui.widgets/*.png
51 | # feeluown.gui.widgets/*.jpg
52 | #
53 | # node
54 | node_modules
55 | 
56 | # rope
57 | *.out
58 | .ropeproject
59 | 
60 | dist/
61 | build/
62 | feeluown.egg-info/
63 | .eggs/
64 | 
65 | # Emacs
66 | .\#*
67 | \#*\#
68 | .dir-locals.el
69 | 
70 | # virtualenv
71 | .venv
72 | .venv/
73 | 
74 | # others
75 | *.html
76 | 
77 | .pytest_cache/
78 | *.dat
79 | htmlcov/
80 | .aider*
81 | 


--------------------------------------------------------------------------------
/feeluown/local/ui.py:
--------------------------------------------------------------------------------
 1 | from feeluown.utils.reader import wrap
 2 | from feeluown.gui.page_containers.table import Renderer
 3 | 
 4 | 
 5 | class LibraryRenderer(Renderer):
 6 |     def __init__(self, songs, albums, artists):
 7 |         self.songs = songs
 8 |         self.albums = albums
 9 |         self.artists = artists
10 | 
11 |     async def render(self):
12 |         self.meta_widget.show()
13 |         self.tabbar.show()
14 |         self.tabbar.library_mode()
15 | 
16 |         # render songs
17 |         self.show_songs(reader=wrap(self.songs), show_count=True)
18 | 
19 |         # bind signals
20 |         self.toolbar.filter_albums_needed.connect(
21 |             lambda types: self.albums_table.model().filter_by_types(types))
22 |         self.tabbar.show_songs_needed.connect(
23 |             lambda: self.show_songs(reader=wrap(self.songs),
24 |                                     show_count=True))
25 |         self.tabbar.show_albums_needed.connect(lambda: self.show_albums(self.albums))
26 |         self.tabbar.show_artists_needed.connect(lambda: self.show_artists(self.artists))
27 | 


--------------------------------------------------------------------------------
/examples/library_basic_usage.py:
--------------------------------------------------------------------------------
 1 | #! /usr/bin/env python3
 2 | # -*- coding: utf-8 -*-
 3 | 
 4 | """
 5 | You should install two package from pypi:
 6 | 
 7 | 1. fuo-netease
 8 | 2. fuo-xiami
 9 | """
10 | 
11 | import logging
12 | from feeluown.library import Library
13 | 
14 | from fuo_xiami import provider as xp
15 | from fuo_netease import provider as np
16 | 
17 | logging.basicConfig()
18 | logger = logging.getLogger('feeluown')
19 | logger.setLevel(logging.DEBUG)
20 | 
21 | lib = Library()
22 | lib.register(xp)
23 | lib.register(np)
24 | 
25 | 
26 | def test_list_song_standby():
27 |     """
28 |     使用 library.list_song_standby 接口
29 |     """
30 |     result = xp.search('小小恋歌 新垣结衣', limit=2)
31 |     song = result.songs[0]  # 虾米音乐没有这首歌的资源
32 |     assert song.url == ''
33 | 
34 |     standby_songs = lib.list_song_standby(song)
35 |     for index, ss in enumerate(standby_songs):  # pylint: disable=all
36 |         print(index, ss.source, ss.title, ss.artists_name, ss.url)
37 | 
38 | 
39 | def main():
40 |     test_list_song_standby()
41 | 
42 | 
43 | if __name__ == '__main__':
44 |     main()
45 | 


--------------------------------------------------------------------------------
/feeluown/nowplaying/linux/__init__.py:
--------------------------------------------------------------------------------
 1 | import logging
 2 | 
 3 | logger = logging.getLogger(__name__)
 4 | 
 5 | 
 6 | def run_mpris2_server(app):
 7 |     # pylint: disable=import-outside-toplevel
 8 |     try:
 9 |         import dbus
10 |         import dbus.service
11 |         import dbus.mainloop.pyqt6
12 |     except ImportError as e:
13 |         logger.error("can't run mpris2 server: %s",  str(e))
14 |     else:
15 |         from .mpris2 import Mpris2Service, BusName
16 | 
17 |         # check if a mainloop was already set
18 |         mainloop = dbus.get_default_main_loop()
19 |         if mainloop is not None:
20 |             logger.warning("mpris2 service already enabled? "
21 |                            "maybe you should remove feeluown-mpris2-plugin")
22 |             return
23 | 
24 |         # set the mainloop before any dbus operation
25 |         dbus.mainloop.pyqt6.DBusQtMainLoop(set_as_default=True)
26 |         session_bus = dbus.SessionBus()
27 |         bus = dbus.service.BusName(BusName, session_bus)
28 |         service = Mpris2Service(app, bus)
29 |         service.enable()
30 | 


--------------------------------------------------------------------------------
/feeluown/local/schemas.py:
--------------------------------------------------------------------------------
 1 | # -*- coding: utf-8 -*-
 2 | from pydantic import ConfigDict, BaseModel, Field
 3 | 
 4 | 
 5 | DEFAULT_TITLE = DEFAULT_ARTIST_NAME = DEFAULT_ALBUM_NAME = 'Unknown'
 6 | 
 7 | 
 8 | class Common(BaseModel):
 9 |     model_config = ConfigDict(populate_by_name=True, extra="ignore")
10 | 
11 |     duration: float
12 |     title: str = DEFAULT_TITLE
13 | 
14 |     artists_name: str = Field(DEFAULT_ARTIST_NAME, alias='artist')
15 |     album_name: str = Field(DEFAULT_ALBUM_NAME, alias='album')
16 |     album_artist_name: str = Field(DEFAULT_ARTIST_NAME, alias='albumartist')
17 | 
18 |     track: str = Field('1/1', alias='tracknumber')
19 |     disc: str = Field('1/1', alias='discnumber')
20 | 
21 |     date: str = ''
22 |     genre: str = ''
23 | 
24 | 
25 | class EasyMP3Model(Common):
26 |     pass
27 | 
28 | 
29 | class APEModel(Common):
30 |     pass
31 | 
32 | 
33 | class FLACModel(Common):
34 |     track_number: int = Field(1, alias='tracknumber')
35 |     disc_number: int = Field(1, alias='discnumber')
36 |     track_total: int = Field(1, alias='tracktotal')
37 |     disc_total: int = Field(1, alias='disctotal')
38 | 


--------------------------------------------------------------------------------
/feeluown/server/handlers/sub.py:
--------------------------------------------------------------------------------
 1 | import re
 2 | 
 3 | from .cmd import Cmd
 4 | from .excs import HandlerException
 5 | from .base import AbstractHandler
 6 | 
 7 | 
 8 | class SubHandler(AbstractHandler):
 9 |     cmds = ('sub', )
10 | 
11 |     def handle(self, cmd: Cmd):
12 |         return self.handle_sub(*cmd.args)
13 | 
14 |     def handle_sub(self, *topics):
15 |         pubsub_gateway = self._app.pubsub_gateway
16 | 
17 |         matched_topics = []
18 |         for topic in topics:
19 |             # Keep backward compatibility.
20 |             if topic.startswith('topic.'):
21 |                 topic = topic[6:]
22 | 
23 |             p = re.compile(rf'{topic}')
24 |             for topic_name in pubsub_gateway.topics:
25 |                 m = p.match(topic_name)
26 |                 if m is not None and m.group(0) == topic_name:
27 |                     matched_topics.append(topic_name)
28 | 
29 |             if matched_topics:
30 |                 for each in matched_topics:
31 |                     pubsub_gateway.link(each, self.session)
32 |             else:
33 |                 raise HandlerException(f"{topic}: topic not found")
34 | 


--------------------------------------------------------------------------------
/feeluown/gui/widgets/statusline_items/plugin.py:
--------------------------------------------------------------------------------
 1 | from PyQt6.QtCore import Qt, QRectF
 2 | from PyQt6.QtGui import QTextOption
 3 | 
 4 | from feeluown.gui.widgets.statusline import StatuslineLabel
 5 | 
 6 | 
 7 | class PluginStatus(StatuslineLabel):
 8 |     def __init__(self, app, parent=None):
 9 |         super().__init__(app, parent)
10 |         self._app = app
11 | 
12 |         self._total_count = 0
13 |         self._enabled_count = 0
14 |         self._status_color = "blue"
15 |         self._app.plugin_mgr.scan_finished.connect(self.on_scan_finished)
16 | 
17 |     def on_scan_finished(self, plugins):
18 |         self._total_count = len(plugins)
19 |         for plugin in plugins:
20 |             if plugin.is_enabled:
21 |                 self._enabled_count += 1
22 |         plugins_alias = "\n".join([p.alias for p in plugins])
23 |         self.setToolTip("已经加载的插件:\n{}".format(plugins_alias))
24 |         self.setText(f"{self._enabled_count}")
25 | 
26 |     def drawInner(self, painter):
27 |         inner_rect = QRectF(0, 0, self._inner_width, self._inner_height)
28 |         painter.drawText(inner_rect, "☯", QTextOption(Qt.AlignmentFlag.AlignCenter))
29 | 


--------------------------------------------------------------------------------
/feeluown/player/__init__.py:
--------------------------------------------------------------------------------
 1 | from .metadata import MetadataFields, Metadata
 2 | from .playlist import PlaybackMode, PlaylistRepeatMode, PlaylistShuffleMode, \
 3 |     PlaylistPlayModelStage
 4 | from .base_player import State
 5 | from .mpvplayer import MpvPlayer as Player
 6 | from .playlist import PlaylistMode, Playlist
 7 | from .metadata_assembler import MetadataAssembler
 8 | from .fm import FM
 9 | from .radio import SongRadio, SongsRadio
10 | from .lyric import LiveLyric, parse_lyric_text, Line as LyricLine, Lyric
11 | from .recently_played import RecentlyPlayed
12 | from .delegate import PlayerPositionDelegate
13 | 
14 | 
15 | __all__ = (
16 |     'PlaybackMode',
17 |     'PlaylistRepeatMode',
18 |     'PlaylistShuffleMode',
19 |     'PlaylistPlayModelStage',
20 |     'State',
21 | 
22 |     'FM',
23 |     'PlaylistMode',
24 |     'SongRadio',
25 |     'SongsRadio',
26 | 
27 |     'Player',
28 |     'Playlist',
29 |     'PlayerPositionDelegate',
30 | 
31 |     'Metadata',
32 |     'MetadataFields',
33 |     'MetadataAssembler',
34 | 
35 |     'LiveLyric',
36 |     'parse_lyric_text',
37 |     'LyricLine',
38 |     'Lyric',
39 | 
40 |     'RecentlyPlayed',
41 | )
42 | 


--------------------------------------------------------------------------------
/tests/test_config.py:
--------------------------------------------------------------------------------
 1 | import pytest
 2 | 
 3 | from feeluown.config import Config
 4 | 
 5 | 
 6 | @pytest.fixture
 7 | def config():
 8 |     c = Config()
 9 |     c.deffield('VERBOSE', type_=int, default=1)
10 |     return c
11 | 
12 | 
13 | def test_config_set_known_field(config):
14 |     config.VERBOSE = 2
15 |     # The value should be changed.
16 |     assert config.VERBOSE == 2
17 | 
18 | 
19 | def test_config_set_unknown_field(config):
20 |     """
21 |     Set unknown field should cause no effects.
22 |     Access unknown field should return a Config object.
23 |     """
24 |     config.hey = 0
25 |     assert isinstance(config.hey, Config)
26 |     config.plugin.X = 0  # should not raise error
27 |     assert isinstance(config.plugin.X, Config)
28 | 
29 | 
30 | def test_config_set_subconfig(config):
31 |     """Field is a config object and it should just works"""
32 |     config.deffield('nowplaying', type_=Config)
33 |     config.nowplaying = Config()
34 |     config.nowplaying.deffield('control', type_=True, default=False)
35 |     assert config.nowplaying.control is False
36 |     config.nowplaying.control = True
37 |     assert config.nowplaying.control is True
38 | 


--------------------------------------------------------------------------------
/feeluown/local/provider_ui.py:
--------------------------------------------------------------------------------
 1 | from typing import TYPE_CHECKING
 2 | 
 3 | from feeluown.gui.provider_ui import AbstractProviderUi
 4 | from feeluown.utils import aio
 5 | 
 6 | from .ui import LibraryRenderer
 7 | from .provider import provider
 8 | 
 9 | if TYPE_CHECKING:
10 |     from feeluown.app.gui_app import GuiApp
11 | 
12 | 
13 | class LocalProviderUi(AbstractProviderUi):
14 | 
15 |     def __init__(self, app: 'GuiApp'):
16 |         self._app = app
17 | 
18 |     @property
19 |     def provider(self):
20 |         return provider
21 | 
22 |     def register_pages(self, route):
23 |         route('/local')(show_provider)
24 | 
25 |     def login_or_go_home(self):
26 |         self._app.browser.goto(uri='/local')
27 | 
28 | 
29 | def show_provider(req):
30 |     if hasattr(req, 'ctx'):
31 |         app: 'GuiApp' = req.ctx['app']
32 |     else:
33 |         app = req  # 兼容老版本
34 |     app.pl_uimgr.clear()
35 |     # app.playlists.add(provider.playlists)
36 | 
37 |     app.ui.left_panel.my_music_con.hide()
38 |     app.ui.left_panel.playlists_con.hide()
39 | 
40 |     aio.run_afn(app.ui.table_container.set_renderer,
41 |                 LibraryRenderer(provider.songs, provider.albums, provider.artists))
42 | 


--------------------------------------------------------------------------------
/feeluown/gui/mimedata.py:
--------------------------------------------------------------------------------
 1 | from PyQt6.QtCore import QMimeData
 2 | 
 3 | from feeluown.library import ModelType
 4 | 
 5 | 
 6 | model_mimetype_map = {
 7 |     ModelType.dummy.value: "fuo-model/x-dummy",
 8 |     ModelType.song.value: "fuo-model/x-song",
 9 |     ModelType.playlist.value: "fuo-model/x-playlist",
10 |     ModelType.album.value: "fuo-model/x-album",
11 |     ModelType.artist.value: "fuo-model/x-artist",
12 |     ModelType.lyric.value: "fuo-model/x-lyric",
13 |     ModelType.user.value: "fuo-model/x-user",
14 | }
15 | 
16 | 
17 | def get_model_mimetype(model):
18 |     return model_mimetype_map[ModelType(model.meta.model_type).value]
19 | 
20 | 
21 | class ModelMimeData(QMimeData):
22 |     def __init__(self, model):
23 |         super().__init__()
24 | 
25 |         self.model = model
26 |         self._mimetype = get_model_mimetype(model)
27 | 
28 |     def setData(self, format, model):
29 |         self._model = model
30 | 
31 |     def formats(self):
32 |         return [self._mimetype]
33 | 
34 |     def hasFormat(self, format):
35 |         return format == self._mimetype
36 | 
37 |     def data(self, format):
38 |         if format == self._mimetype:
39 |             return self.model
40 |         return None
41 | 


--------------------------------------------------------------------------------
/tests/library/test_model_v2.py:
--------------------------------------------------------------------------------
 1 | import pytest
 2 | import pydantic
 3 | 
 4 | from feeluown.library import SongModel, BriefAlbumModel, BriefArtistModel, BriefSongModel
 5 | 
 6 | 
 7 | def test_create_song_model_basic():
 8 |     identifier = '1'
 9 |     brief_album = BriefAlbumModel(identifier='1', source='x',
10 |                                   name='Film', artists_name='Audrey')
11 |     brief_artist = BriefArtistModel(identifier='1', source='x', name='Audrey')
12 |     # song should be created
13 |     song = SongModel(identifier=identifier, source='x', title='Moon', album=brief_album,
14 |                      artists=[brief_artist], duration=240000)
15 |     # check song's attribute value
16 |     assert song.artists_name == 'Audrey'
17 | 
18 |     # song model cache get/set
19 |     song.cache_set('count', 1)
20 |     assert song.cache_get('count') == (1, True)
21 | 
22 | 
23 | def test_create_model_with_extra_field():
24 |     with pytest.raises(pydantic.ValidationError):
25 |         BriefSongModel(identifier=1, source='x', unk=0)
26 | 
27 | 
28 | def test_song_model_is_hashable():
29 |     """
30 |     Song model must be hashable.
31 |     """
32 |     song = BriefSongModel(identifier=1, source='x')
33 |     hash(song)
34 | 


--------------------------------------------------------------------------------
/tests/test_argparse.py:
--------------------------------------------------------------------------------
 1 | import argparse
 2 | 
 3 | from feeluown.argparser import add_common_cmds, add_server_cmds
 4 | 
 5 | 
 6 | def test_argparse():
 7 |     parser = argparse.ArgumentParser()
 8 |     subparsers = parser.add_subparsers(
 9 |         dest='cmd',
10 |     )
11 |     add_common_cmds(subparsers)
12 |     add_server_cmds(subparsers)
13 | 
14 |     # Test play parser.
15 |     argv = ['play', 'fuo://xxx']
16 |     args = parser.parse_args(argv)
17 |     assert args.cmd == argv[0]
18 | 
19 |     # Test format parser.
20 |     argv = ['search', 'zjl', '--format', 'json']
21 |     args = parser.parse_args(argv)
22 |     assert args.format == argv[3]
23 |     argv = ['search', 'zjl', '--json']
24 |     args = parser.parse_args(argv)
25 |     assert args.json is True
26 | 
27 |     # Test search parser.
28 |     argv = ['search', 'zjl', '--source', 'xx', '--s', 'yy', '--type', 'song']
29 |     args = parser.parse_args(argv)
30 |     assert args.keyword == argv[1]
31 |     assert 'xx' in args.source and 'yy' in args.source
32 |     assert args.type == [argv[-1]]
33 |     # Test parse invalid args.
34 |     argv = ['search', 'zjl', '-t', 'xx']
35 |     args, remain = parser.parse_known_args(argv)
36 |     assert remain == argv[-2:]
37 | 


--------------------------------------------------------------------------------
/feeluown/library/__init__.py:
--------------------------------------------------------------------------------
 1 | # flake8: noqa
 2 | from .library import Library
 3 | from .provider import AbstractProvider, ProviderV2, Provider
 4 | from .flags import Flags as ProviderFlags
 5 | from .model_state import ModelState
 6 | from .model_protocol import (  # deprecated
 7 |     BriefSongProtocol,
 8 |     BriefVideoProtocol,
 9 |     BriefArtistProtocol,
10 |     BriefAlbumProtocol,
11 |     BriefUserProtocol,
12 |     SongProtocol,
13 | )
14 | from .models import ModelFlags, BaseModel, ModelType, SearchType, \
15 |     SongModel, BriefSongModel, \
16 |     BriefArtistModel, BriefAlbumModel, \
17 |     BriefCommentModel, CommentModel, \
18 |     BriefUserModel, UserModel, \
19 |     LyricModel, VideoModel, BriefVideoModel, \
20 |     ArtistModel, AlbumModel, PlaylistModel, BriefPlaylistModel, \
21 |     fmt_artists_names, AlbumType, SimpleSearchResult, \
22 |     get_modelcls_by_type, MediaFlags, \
23 |     V2SupportedModelTypes
24 | from .excs import NoUserLoggedIn, ModelNotFound, \
25 |     ProviderAlreadyExists, ResourceNotFound, MediaNotFound
26 | from .provider_protocol import *
27 | from .uri import (
28 |     Resolver, reverse, resolve, ResolverNotFound, ResolveFailed,
29 |     parse_line, NS_TYPE_MAP,
30 | )
31 | from .collection import Collection, CollectionType
32 | 


--------------------------------------------------------------------------------
/feeluown/gui/pages/recommendation_daily_songs.py:
--------------------------------------------------------------------------------
 1 | from typing import TYPE_CHECKING
 2 | 
 3 | from feeluown.library import SupportsRecListDailySongs
 4 | from feeluown.gui.page_containers.table import TableContainer, Renderer
 5 | from feeluown.gui.page_containers.scroll_area import ScrollArea
 6 | from feeluown.utils.aio import run_fn
 7 | from feeluown.utils.reader import create_reader
 8 | from .template import render_error_message
 9 | 
10 | 
11 | if TYPE_CHECKING:
12 |     from feeluown.app.gui_app import GuiApp
13 | 
14 | 
15 | async def render(req, **_):
16 |     app: "GuiApp" = req.ctx["app"]
17 |     pvd_ui = app.current_pvd_ui_mgr.get()
18 |     if pvd_ui is None:
19 |         return await render_error_message(app, "当前资源提供方未知,无法浏览该页面")
20 | 
21 |     provider = pvd_ui.provider
22 | 
23 |     scroll_area = ScrollArea()
24 |     body = TableContainer(app, scroll_area)
25 |     scroll_area.setWidget(body)
26 |     app.ui.right_panel.set_body(scroll_area)
27 |     if isinstance(provider, SupportsRecListDailySongs):
28 |         songs = await run_fn(provider.rec_list_daily_songs)
29 |         renderer = Renderer()
30 |         await body.set_renderer(renderer)
31 |         renderer.show_songs(create_reader(songs))
32 |         renderer.meta_widget.show()
33 |         renderer.meta_widget.title = "每日推荐歌曲"
34 | 


--------------------------------------------------------------------------------
/feeluown/player/recently_played.py:
--------------------------------------------------------------------------------
 1 | from feeluown.library import ModelType
 2 | from feeluown.utils.utils import DedupList
 3 | 
 4 | 
 5 | class RecentlyPlayed:
 6 |     """
 7 |     RecentlyPlayed records recently played models, currently including songs
 8 |     and videos. Maybe artists and albums will be recorded in the future.
 9 |     """
10 |     def __init__(self, playlist):
11 |         # Store recently played songs. Newest song is appended to left.
12 |         self._songs = DedupList()
13 | 
14 |         playlist.song_changed_v2.connect(self._on_song_played)
15 | 
16 |     def init_from_models(self, models):
17 |         for model in models:
18 |             if ModelType(model.meta.model_type) is ModelType.song:
19 |                 self._songs.append(model)
20 | 
21 |     def list_songs(self):
22 |         """List recently played songs (list of BriefSongModel).
23 |         """
24 |         return list(self._songs.copy())
25 | 
26 |     def _on_song_played(self, song, _):
27 |         # Remove the song and place the song at the very first if it already exists.
28 |         if song is None:
29 |             return
30 |         if song in self._songs:
31 |             self._songs.remove(song)
32 |         if len(self._songs) >= 100:
33 |             self._songs.pop()
34 |         self._songs.insert(0, song)
35 | 


--------------------------------------------------------------------------------
/feeluown/nowplaying/__init__.py:
--------------------------------------------------------------------------------
 1 | import sys
 2 | import logging
 3 | 
 4 | 
 5 | logger = logging.getLogger(__name__)
 6 | 
 7 | 
 8 | async def run_nowplaying_server(app):
 9 |     try:
10 |         await run_nowplaying_server_internal(app)
11 |     except ImportError as e:
12 |         logger.warning(f"run nowplaying service failed: '{e}'")
13 |     except:  # noqa
14 |         logger.exception('run nowplaying service error')
15 | 
16 | 
17 | def get_service_cls():
18 |     """
19 |     Only public for testing.
20 |     """
21 |     # pylint: disable=import-outside-toplevel
22 |     from .nowplaying import NowPlayingService
23 | 
24 |     if sys.platform == 'darwin':
25 |         from .macos import MacosMixin
26 | 
27 |         class Service(MacosMixin, NowPlayingService):
28 |             pass
29 |     else:
30 |         Service = NowPlayingService
31 |     return Service
32 | 
33 | 
34 | async def run_nowplaying_server_internal(app):
35 |     # pylint: disable=import-outside-toplevel
36 |     if sys.platform == 'linux':
37 |         from .linux import run_mpris2_server
38 |         run_mpris2_server(app)
39 |     elif sys.platform in ('win32', 'darwin'):
40 |         service = get_service_cls()(app)
41 |         await service.start()
42 |     else:
43 |         logger.warning('nowplaying is not supported on %s', sys.platform)
44 | 


--------------------------------------------------------------------------------
/feeluown/gui/pages/toplist.py:
--------------------------------------------------------------------------------
 1 | # pylint: disable=duplicate-code
 2 | # FIXME: remove duplicate code lator
 3 | from typing import TYPE_CHECKING
 4 | 
 5 | from feeluown.library import SupportsToplist
 6 | from feeluown.gui.page_containers.table import TableContainer, Renderer
 7 | from feeluown.gui.page_containers.scroll_area import ScrollArea
 8 | from feeluown.utils.aio import run_fn
 9 | from feeluown.utils.reader import create_reader
10 | from .template import render_error_message
11 | 
12 | 
13 | if TYPE_CHECKING:
14 |     from feeluown.app.gui_app import GuiApp
15 | 
16 | 
17 | async def render(req, **_):
18 |     app: "GuiApp" = req.ctx["app"]
19 |     pvd_ui = app.current_pvd_ui_mgr.get()
20 |     if pvd_ui is None:
21 |         return await render_error_message(app, "当前资源提供方未知,无法浏览该页面")
22 | 
23 |     provider = pvd_ui.provider
24 |     scroll_area = ScrollArea()
25 |     body = TableContainer(app, scroll_area)
26 |     scroll_area.setWidget(body)
27 |     app.ui.right_panel.set_body(scroll_area)
28 |     if isinstance(provider, SupportsToplist):
29 |         playlists = await run_fn(provider.toplist_list)
30 |         renderer = Renderer()
31 |         await body.set_renderer(renderer)
32 |         renderer.show_playlists(create_reader(playlists))
33 |         renderer.meta_widget.show()
34 |         renderer.meta_widget.title = "排行榜"
35 | 


--------------------------------------------------------------------------------
/feeluown/server/data_structure.py:
--------------------------------------------------------------------------------
 1 | from dataclasses import dataclass
 2 | 
 3 | 
 4 | @dataclass
 5 | class SessionOptions:
 6 |     # Always make the human-friendly protocol version as the default.
 7 |     rpc_version: str = '2.0'     # RPC protocol version.
 8 |     pubsub_version: str = '1.0'  # Pubsub protocol version.
 9 | 
10 | 
11 | class Request:
12 |     """fuo 协议请求对象"""
13 |     # pylint: disable=too-many-arguments,too-many-positional-arguments
14 |     # FIXME: maybe add a property 'stdin_content' for Request.
15 |     def __init__(self, cmd, cmd_args=None,
16 |                  cmd_options=None, options=None,
17 |                  has_heredoc=False, heredoc_word=None):
18 |         self.cmd = cmd
19 |         self.cmd_args = cmd_args or []
20 |         self.cmd_options = cmd_options or {}
21 | 
22 |         self.options = options or {}
23 | 
24 |         self.has_heredoc = has_heredoc
25 |         self.heredoc_word = heredoc_word
26 | 
27 |     def set_heredoc_body(self, body):
28 |         assert self.has_heredoc is True and self.heredoc_word
29 |         self.cmd_args = [body]
30 | 
31 | 
32 | class Response:
33 |     def __init__(self, ok=True, text='', req=None, options=None):
34 |         self.code = 'OK' if ok else 'Oops'
35 |         self.text = text
36 |         self.options = options or {}
37 | 
38 |         self.req = req
39 | 


--------------------------------------------------------------------------------
/feeluown/entry_points/run.py:
--------------------------------------------------------------------------------
 1 | import os
 2 | from feeluown.utils.patch import patch_janus, patch_qeventloop, patch_mutagen, \
 3 |     patch_pydantic
 4 | patch_janus()
 5 | patch_mutagen()
 6 | patch_pydantic()
 7 | 
 8 | os.environ.setdefault('QT_API', 'pyqt6')
 9 | os.environ.setdefault('FEELUOWN_QT_API', 'pyqt6')
10 | 
11 | try:
12 |     patch_qeventloop()
13 | except ImportError:
14 |     # qasync/qt is not installed
15 |     # FIXME: should not catch the error at here
16 |     pass
17 | 
18 | # pylint: disable=wrong-import-position
19 | from feeluown.argparser import create_cli_parser  # noqa: E402
20 | from feeluown.utils.utils import is_port_inuse  # noqa: E402
21 | from .run_cli import run_cli  # noqa: E402
22 | from .run_app import run_app  # noqa: E402
23 | 
24 | 
25 | def run():
26 |     """feeluown entry point.
27 |     """
28 | 
29 |     args = create_cli_parser().parse_args()
30 | 
31 |     if args.cmd is not None:  # Only need to run some commands.
32 |         if args.cmd == 'genicon':
33 |             return run_cli(args)
34 | 
35 |         # If daemon is started, we send commands to daemon directly
36 |         # we simple think the daemon is started as long as
37 |         # the port 23333 is in use.
38 |         # TODO: allow specify port in command line args.
39 |         if is_port_inuse(23333):
40 |             return run_cli(args)
41 | 
42 |     return run_app(args)
43 | 


--------------------------------------------------------------------------------
/feeluown/server/dslv1/codegen.py:
--------------------------------------------------------------------------------
 1 | from feeluown.server import Request
 2 | from .lexer import furi_re, integer_re, float_re
 3 | 
 4 | 
 5 | def options_to_str(options):
 6 |     """
 7 |     TODO: support complex value, such as list
 8 |     """
 9 |     return ",".join(f"{k}={v}"
10 |                     for k, v in options.items())
11 | 
12 | 
13 | def unparse(request: Request):
14 |     """Generate source code for the request object"""
15 | 
16 |     def escape(value):
17 |         # if value is not furi/float/integer, than we surround the value
18 |         # with double quotes
19 |         regex_list = (furi_re, float_re, integer_re)
20 |         for regex in regex_list:
21 |             if regex.match(value):
22 |                 break
23 |         else:
24 |             value = f'"{value}"'
25 |         return value
26 | 
27 |     # TODO: allow heredoc and args appear at the same time
28 |     args_str = '' if request.has_heredoc else \
29 |         ' '.join((escape(arg) for arg in request.cmd_args))
30 |     options_str = options_to_str(request.cmd_options)
31 |     if options_str:
32 |         options_str = f'[{options_str}]'
33 |     raw = f'{request.cmd} {args_str} {options_str} #: {options_to_str(request.options)}'
34 |     if request.has_heredoc:
35 |         word = request.heredoc_word
36 |         raw += f' <<{word}\n{request.cmd_args[0]}\n{word}\n\n'
37 |     return raw
38 | 


--------------------------------------------------------------------------------
/feeluown/utils/audio.py:
--------------------------------------------------------------------------------
 1 | from mutagen.mp3 import MP3
 2 | from mutagen.mp4 import MP4, AtomDataType
 3 | from mutagen.flac import FLAC
 4 | 
 5 | 
 6 | def read_audio_cover(fpath):
 7 |     """read audio cover binary data and format"""
 8 | 
 9 |     if fpath.endswith('mp3'):
10 |         mp3 = MP3(fpath)
11 |         apic = mp3.get('APIC:')
12 |         if apic is not None:
13 |             if apic.mime in ('image/jpg', 'image/jpeg'):
14 |                 fmt = 'jpg'
15 |             else:
16 |                 fmt = 'png'
17 |             return apic.data, fmt
18 | 
19 |     elif fpath.endswith('m4a'):
20 |         mp4 = MP4(fpath)
21 |         tags = mp4.tags
22 |         if tags is not None:
23 |             covers = tags.get('covr')
24 |             if covers:
25 |                 cover = covers[0]
26 |                 if cover.imageformat == AtomDataType.JPEG:
27 |                     fmt = 'jpg'
28 |                 else:
29 |                     fmt = 'png'
30 |                 return cover, fmt
31 | 
32 |     elif fpath.endswith('flac'):
33 |         flac = FLAC(fpath)
34 |         covers = flac.pictures
35 |         if covers:
36 |             cover = covers[0]
37 |             if cover.mime in ('image/jpg', 'image/jpeg'):
38 |                 fmt = 'jpg'
39 |             else:
40 |                 fmt = 'png'
41 |             return cover.data, fmt
42 | 
43 |     return None, None
44 | 


--------------------------------------------------------------------------------
/tests/gui/widgets/test_widgets.py:
--------------------------------------------------------------------------------
 1 | import time
 2 | from feeluown.utils.reader import create_reader
 3 | from feeluown.library.models import CommentModel, BriefUserModel, BriefCommentModel
 4 | from feeluown.gui.widgets.comment_list import CommentListModel, CommentListView
 5 | 
 6 | 
 7 | def test_comment_list_view(qtbot):
 8 |     user = BriefUserModel(identifier='fuo-bot',
 9 |                           source='fuo',
10 |                           name='随风而去')
11 |     content = ('有没有一首歌会让你很想念,有没有一首歌你会假装听不见,'
12 |                '听了又掉眼泪,却按不下停止健')
13 |     brief_comment = BriefCommentModel(identifier='ouf',
14 |                                       user_name='world',
15 |                                       content='有没有人曾告诉你')
16 |     comment = CommentModel(identifier='fuo',
17 |                            source='fuo',
18 |                            user=user,
19 |                            liked_count=1,
20 |                            content=content,
21 |                            time=int(time.time()),
22 |                            parent=brief_comment,)
23 |     comment2 = comment.model_copy()
24 |     comment2.content = 'hello world'
25 | 
26 |     reader = create_reader([comment, comment2, comment])
27 |     model = CommentListModel(reader)
28 |     widget = CommentListView()
29 |     widget.setModel(model)
30 |     widget.show()
31 | 
32 |     qtbot.addWidget(widget)
33 | 


--------------------------------------------------------------------------------
/tests/app/conftest.py:
--------------------------------------------------------------------------------
 1 | import asyncio
 2 | from unittest import mock
 3 | 
 4 | import pytest
 5 | import pytest_asyncio
 6 | 
 7 | from feeluown.argparser import create_cli_parser
 8 | from feeluown.app import create_config
 9 | from feeluown.plugin import PluginsManager
10 | from feeluown.collection import CollectionManager
11 | from feeluown.utils.dispatch import Signal
12 | from feeluown.player import PlayerPositionDelegate
13 | 
14 | 
15 | @pytest_asyncio.fixture
16 | async def signal_aio_support():
17 |     Signal.setup_aio_support()
18 |     yield
19 |     Signal.teardown_aio_support()
20 | 
21 | 
22 | @pytest.fixture
23 | def argsparser():
24 |     parser = create_cli_parser()
25 |     return parser
26 | 
27 | 
28 | @pytest.fixture
29 | def args_test(argsparser):
30 |     return argsparser.parse_args(args=[])
31 | 
32 | 
33 | @pytest.fixture
34 | def args(argsparser):
35 |     return argsparser.parse_args(args=[])
36 | 
37 | 
38 | @pytest.fixture
39 | def config():
40 |     return create_config()
41 | 
42 | 
43 | @pytest.fixture
44 | def noharm(mocker):
45 |     mocker.patch('feeluown.app.app.Player')
46 |     mocker.patch.object(PluginsManager, 'enable_plugins')
47 |     mocker.patch.object(CollectionManager, 'scan')
48 |     # To avoid resource leak::
49 |     #   RuntimeWarning: coroutine 'xxx' was never awaited
50 |     PlayerPositionDelegate.start = mock.MagicMock(return_value=asyncio.Future())
51 | 


--------------------------------------------------------------------------------
/pyinstaller_hooks/hook-feeluown.py:
--------------------------------------------------------------------------------
 1 | import os
 2 | import sys
 3 | import ctypes
 4 | from PyInstaller.utils.hooks import collect_data_files, collect_entry_point
 5 | 
 6 | 
 7 | assets_files = [
 8 |     '**/*.png',
 9 |     '**/*.qss',
10 |     '**/*.svg',
11 |     '**/*.ico',
12 |     '**/*.icns',
13 |     '**/*.colors',
14 |     '**/*.mo',
15 | ]
16 | datas, hiddenimports = collect_entry_point('fuo.plugins_v1')
17 | 
18 | if 'fuo_ytmusic' in hiddenimports:
19 |     hiddenimports.append('ytmusicapi')
20 | 
21 | # aionowplaying is conditionally imported.
22 | if os.name == 'nt':
23 |     hiddenimports.append('aionowplaying')
24 |     # pyinstaller can't detect 'aionowplaying.interface.windows' is imported.
25 |     hiddenimports.append('aionowplaying.interface.windows')
26 | if sys.platform == 'darwin':
27 |     hiddenimports.append('aionowplaying.interface.macos')
28 |     hiddenimports.append('feeluown.aionowplaying.macos')
29 | 
30 | # Collect feeluown's resource files, like icons, qss files, etc.
31 | # Collect plugins' resource files.
32 | datas += collect_data_files('feeluown', includes=assets_files)
33 | for pkg in hiddenimports:
34 |     datas += collect_data_files(pkg, includes=assets_files)
35 | 
36 | 
37 | # Collect mpv dynamic-link library.
38 | if os.name == 'nt':
39 |     mpv_dylib = 'mpv-1.dll'
40 | else:
41 |     mpv_dylib = 'mpv'
42 | mpv_dylib_path = ctypes.util.find_library(mpv_dylib)
43 | binaries = [(mpv_dylib_path, '.'), ]
44 | 


--------------------------------------------------------------------------------
/feeluown/utils/request.py:
--------------------------------------------------------------------------------
 1 | import logging
 2 | import requests
 3 | 
 4 | from feeluown.utils.dispatch import Signal
 5 | from requests.exceptions import ConnectionError, HTTPError, Timeout
 6 | 
 7 | 
 8 | logger = logging.getLogger(__name__)
 9 | 
10 | 
11 | class Request:
12 |     def __init__(self):
13 |         self.connected_signal = Signal()
14 |         self.disconnected_signal = Signal()
15 |         self.slow_signal = Signal()
16 |         self.server_error_signal = Signal()
17 | 
18 |     def get(self, *args, **kw):
19 |         logger.debug('request.get %s %s' % (args, kw))
20 |         if kw.get('timeout') is None:
21 |             kw['timeout'] = 1
22 |         try:
23 |             res = requests.get(*args, **kw)
24 |             self.connected_signal.emit()
25 |             return res
26 |         except ConnectionError:
27 |             self.disconnected_signal.emit()
28 |         except HTTPError:
29 |             self.server_error_signal.emit()
30 |         except Timeout:
31 |             self.slow_signal.emit()
32 |         return None
33 | 
34 |     def post(self, *args, **kw):
35 |         logger.debug('request.post %s %s' % (args, kw))
36 |         try:
37 |             res = requests.post(*args, **kw)
38 |             return res
39 |         except ConnectionError:
40 |             self.disconnected_signal.emit()
41 |         except HTTPError:
42 |             self.server_error_signal.emit()
43 |         except Timeout:
44 |             self.slow_signal.emit()
45 |         return None
46 | 


--------------------------------------------------------------------------------
/feeluown/server/server.py:
--------------------------------------------------------------------------------
 1 | import asyncio
 2 | import logging
 3 | 
 4 | from feeluown.server import handle_request
 5 | from feeluown.server.session import SessionLike
 6 | from .protocol import FuoServerProtocol, ProtocolType, PubsubProtocol
 7 | 
 8 | 
 9 | logger = logging.getLogger(__name__)
10 | 
11 | 
12 | class FuoServer:
13 |     def __init__(self, app, protocol_type):
14 |         self._app = app
15 |         self.protocol_type = protocol_type
16 |         self._loop = None
17 | 
18 |     async def run(self, host, port):
19 |         loop = asyncio.get_event_loop()
20 |         self._loop = loop
21 | 
22 |         try:
23 |             await loop.create_server(self.protocol_factory, host, port)
24 |         except OSError as e:
25 |             raise SystemExit(str(e)) from None
26 | 
27 |         name = 'unknown'
28 |         if self.protocol_type is ProtocolType.rpc:
29 |             name = 'PRC'
30 |         elif self.protocol_type is ProtocolType.pubsub:
31 |             name = 'Pub/Sub'
32 |         logger.info('%s server run at %s:%d', name, host, port)
33 | 
34 |     def protocol_factory(self):
35 |         if self.protocol_type is ProtocolType.rpc:
36 |             protocol_cls = FuoServerProtocol
37 |         else:
38 |             protocol_cls = PubsubProtocol
39 |         return protocol_cls(handle_req=self.handle_req,
40 |                             loop=self._loop,)
41 | 
42 |     async def handle_req(self, req, session: SessionLike):
43 |         return await handle_request(req, self._app, session)
44 | 


--------------------------------------------------------------------------------
/feeluown/server/handlers/base.py:
--------------------------------------------------------------------------------
 1 | from typing import Dict, Type, TypeVar, Optional, TYPE_CHECKING
 2 | 
 3 | from feeluown.server.session import SessionLike
 4 | 
 5 | if TYPE_CHECKING:
 6 |     from feeluown.app.server_app import ServerApp
 7 | 
 8 | 
 9 | T = TypeVar('T', bound='HandlerMeta')
10 | cmd_handler_mapping: Dict[str, 'HandlerMeta'] = {}
11 | 
12 | 
13 | class HandlerMeta(type):
14 |     def __new__(cls: Type[T], name, bases, attrs) -> T:
15 |         klass = type.__new__(cls, name, bases, attrs)
16 |         if 'cmds' in attrs:
17 |             cmds = attrs['cmds']
18 |             if isinstance(cmds, str):
19 |                 cmd_handler_mapping[cmds] = klass
20 |             else:
21 |                 for cmd in cmds:
22 |                     cmd_handler_mapping[cmd] = klass
23 |         return klass
24 | 
25 | 
26 | class AbstractHandler(metaclass=HandlerMeta):
27 |     support_aio_handle = False
28 | 
29 |     def __init__(self, app: 'ServerApp', session: Optional[SessionLike] = None):
30 |         """
31 |         暂时不确定 session 应该设计为什么样的结构。当前主要是为了将它看作一个
32 |         subscriber。大部分 handler 不需要使用到 session 对像,目前只有 SubHandler
33 |         把 session 当作一个 subscriber 看待。
34 |         """
35 |         self._app = app
36 |         self.session = session
37 | 
38 |         self.library = app.library
39 |         self.player = app.player
40 |         self.playlist = app.playlist
41 |         self.live_lyric = app.live_lyric
42 | 
43 |     def handle(self, cmd):
44 |         ...
45 | 
46 |     async def a_handle(self, cmd):
47 |         ...
48 | 


--------------------------------------------------------------------------------
/examples/research/multiple_inheritance.py:
--------------------------------------------------------------------------------
 1 | """
 2 | This example proves the following experience (under multiple inheritance circumstances)
 3 | 
 4 | 1. The `Base.initialize` method is only called once with `super().initialize()`
 5 | """
 6 | class Base:
 7 |     def __init__(self):
 8 |         print('base init')
 9 | 
10 |     def initialize(self):
11 |         print('base initialize')
12 | 
13 |     def run(self):
14 |         print('base run')
15 | 
16 | 
17 | class Widget:
18 |     def __init__(self, parent):
19 |         print('widget init')
20 | 
21 | 
22 | class A(Base):
23 |     def __init__(self):
24 |         super().__init__()
25 |         print('A init')
26 | 
27 |     def initialize(self):
28 |         super().initialize()
29 |         print('a initialize')
30 | 
31 |     def run(self):
32 |         super().run()
33 |         print('a run')
34 | 
35 | 
36 | class B(Base, Widget):
37 |     def __init__(self):
38 |         Base.__init__(self)
39 |         Widget.__init__(self, self)
40 |         print('B init')
41 | 
42 |     def initialize(self):
43 |         super().initialize()
44 |         print('b initialize')
45 | 
46 |     def run(self):
47 |         super().run()
48 |         print('b run')
49 | 
50 | 
51 | class M(A, B):
52 |     def __init__(self):
53 |         super().__init__()
54 | 
55 |     def initialize(self):
56 |         super().initialize()
57 |         print('mixed initialize')
58 | 
59 |     def run(self):
60 |         super().run()
61 |         print('m run')
62 | 
63 | 
64 | m = M()
65 | m.initialize()
66 | m.run()
67 | 


--------------------------------------------------------------------------------
/docs/source/dev_quickstart.rst:
--------------------------------------------------------------------------------
 1 | 本地开发快速上手
 2 | ================
 3 | 首先,非常感谢您愿意贡献自己的力量让 FeelUOwn 播放器变得更好~
 4 | 
 5 | 推荐的开发流程
 6 | --------------
 7 | 
 8 | 这里假设读者已经对 Git 和 Python 相关工具比较熟悉
 9 | 
10 | 1. 在 GitHub 上 fork 一份代码到自己名下
11 | 2. clone 自己 fork 的代码: ``git clone git@github.com:username/FeelUOwn.git``
12 | 3. 在 Home 目录或者项目目录下创建一个虚拟环境(假设你已经安装 Python 3) ::
13 | 
14 |      # 以在 Home 目录下创建虚拟环境为例
15 | 
16 |      # 创建 ~/.venvs 目录,如果之前没有创建的话
17 |      mkdir -p ~/.venvs
18 | 
19 |      # 创建虚拟环境(大家也可以选择用 virtualenv)
20 |      python3 -m venv ~/.venvs/fuo
21 | 
22 |      # 激活虚拟环境
23 |      source ~/.venvs/fuo/bin/activate
24 | 
25 |      # 安装项目依赖
26 |      pip3 install -e .
27 | 
28 | 
29 | .. note::
30 | 
31 |    在 Linux 或者 macOS 下,大家一般都是用 apt-get 或者 brew 将 ``PyQt6`` 安装到 Python 系统包目录,
32 |    也就是说,在虚拟环境里面, **不能 import 到 PyQt6 这个包** 。建议的解决方法是:
33 | 
34 |    1. 创建一个干净的虚拟环境(不包含系统包)
35 |    2. ``pip3 install -e .`` 安装项目以及依赖
36 |    3. 将虚拟环境配置改成包含系统包,将 ``~/.venvs/fuo/pyvenv.cfg``
37 |       配置文件中的 ``include-system-site-packages`` 字段的值改为 ``true``
38 | 
39 |    这样可以尽量避免包版本冲突、以及依赖版本过低的情况
40 | 
41 | 4. 以 debug 模式启动 feeluown ::
42 | 
43 |      # 运行 feeluown -h 查看更多选项
44 |      feeluown --debug
45 | 
46 | 5. 修改代码
47 | 6. push 到自己的 master 或其它分支
48 | 7. 给 FeelUOwn 项目提交 PR
49 | 8. (可选)在开发者群中通知大家进行 review
50 | 
51 | 
52 | 程序启动流程
53 | ----------------------
54 | 
55 | ``feeluown(fuo)`` 命令入口文件为 ``feeluown/entry_points/run.py`` ,主函数为 run 函数。
56 | 
57 | 
58 | .. _feeluown: http://github.com/feeluown/feeluown
59 | .. _廖雪峰的Git教程: https://www.liaoxuefeng.com/wiki/0013739516305929606dd18361248578c67b8067c8c017b000
60 | 


--------------------------------------------------------------------------------
/tests/server/rpc/dslv1/test_lexer.py:
--------------------------------------------------------------------------------
 1 | from unittest import TestCase
 2 | 
 3 | from feeluown.server.dslv1 import Lexer
 4 | from feeluown.server.excs import FuoSyntaxError
 5 | 
 6 | 
 7 | lexer = Lexer()
 8 | 
 9 | 
10 | class TestLexer(TestCase):
11 | 
12 |     def test_bad_string(self):
13 |         with self.assertRaises(FuoSyntaxError):
14 |             list(lexer.tokenize("play 'h"))
15 | 
16 |     def test_bad_cmd_option_1(self):
17 |         with self.assertRaises(FuoSyntaxError):
18 |             list(lexer.tokenize("play ["))
19 | 
20 |     def test_bad_cmd_option_2(self):
21 |         with self.assertRaises(FuoSyntaxError):
22 |             list(lexer.tokenize("play [name"))
23 | 
24 |     def test_cmd_with_options(self):
25 |         tokens = lexer.tokenize("play hay [album=miao]")
26 |         self.assertEqual(next(tokens).value, 'play')
27 |         self.assertEqual(next(tokens).value, 'hay')
28 |         next(tokens)
29 |         self.assertEqual(next(tokens).value, 'album')
30 | 
31 |     def test_cast_int_float_string(self):
32 |         tokens = lexer.tokenize("1 1.5 'aha'")
33 |         self.assertEqual(next(tokens).value, 1)
34 |         self.assertEqual(next(tokens).value, 1.5)
35 |         self.assertEqual(next(tokens).value, 'aha')
36 | 
37 |     def test_heredoc_simple(self):
38 |         """test if lexer can recognize heredoc start"""
39 |         tokens = lexer.tokenize('<>> def func(): pass
39 |     >>> add_hook('app.initialized', func)
40 | 
41 |     .. versionadded:: 3.8
42 |        The *kwargs* keyword argument.
43 |     """
44 |     signal_mgr.add(signal_symbol, func, use_symbol, **kwargs)
45 | 
46 | 
47 | @expose_to_rcfile()
48 | def rm_hook(signal_symbol: str, slot: Callable, use_symbol: bool = False):
49 |     """Remove slot from signal.
50 | 
51 |     If slot_symbol is not connected, this does nothing.
52 |     """
53 |     signal_mgr.remove(signal_symbol, slot, use_symbol=use_symbol)
54 | 


--------------------------------------------------------------------------------
/feeluown/library/standby.py:
--------------------------------------------------------------------------------
 1 | from .models import SongModel
 2 | 
 3 | STANDBY_DEFAULT_MIN_SCORE = 0.5
 4 | STANDBY_FULL_SCORE = 1
 5 | 
 6 | 
 7 | def get_standby_score(origin, standby):
 8 | 
 9 |     # TODO: move this function to utils module
10 |     def duration_ms_to_duration(ms):
11 |         if not ms:  # ms is empty
12 |             return 0
13 |         parts = ms.split(':')
14 |         assert len(parts) in (2, 3), f'invalid duration format: {ms}'
15 |         if len(parts) == 3:
16 |             h, m, s = parts
17 |         else:
18 |             m, s = parts
19 |             h = 0
20 |         return int(h) * 3600 + int(m) * 60 + int(s)
21 | 
22 |     def get_duration(s):
23 |         return s.duration if isinstance(s, SongModel) else \
24 |             duration_ms_to_duration(s.duration_ms)
25 | 
26 |     origin_duration = get_duration(origin)
27 | 
28 |     score = 0
29 |     if not (origin.album_name and origin_duration):
30 |         score_dis = [4, 4, 1, 1]
31 |     else:
32 |         score_dis = [3, 2, 2, 3]
33 | 
34 |     if origin.artists_name == standby.artists_name:
35 |         score += score_dis[0]
36 |     if origin.title == standby.title:
37 |         score += score_dis[1]
38 |     # Only compare album_name when it is not empty.
39 |     if origin.album_name and origin.album_name == standby.album_name:
40 |         score += score_dis[2]
41 |     standby_duration = get_duration(standby)
42 |     if origin_duration and \
43 |             abs(origin_duration - standby_duration) / max(origin_duration, 1) < 0.1:
44 |         score += score_dis[3]
45 | 
46 |     # Debug code for score function
47 |     # print(f"{score}\t('{standby.title}', "
48 |     #       f"'{standby.artists_name}', "
49 |     #       f"'{standby.album_name}', "
50 |     #       f"'{standby.duration_ms}')")
51 |     return score / 10
52 | 


--------------------------------------------------------------------------------
/feeluown/server/handlers/search.py:
--------------------------------------------------------------------------------
 1 | import logging
 2 | 
 3 | from feeluown.library import SupportsSongGet
 4 | from .base import AbstractHandler
 5 | 
 6 | logger = logging.getLogger(__name__)
 7 | 
 8 | 
 9 | class SearchHandler(AbstractHandler):
10 |     """
11 | 
12 |     Supported query parameters:
13 | 
14 |     search keyword
15 |     search keyword [type=playlist]
16 | 
17 |     TODO:
18 | 
19 |     search keyword [type=all,source=netease,limit=10]
20 |     """
21 | 
22 |     cmds = 'search'
23 | 
24 |     def handle(self, cmd):
25 |         return self.search(cmd.args[0], cmd.options)
26 | 
27 |     def search(self, keyword, options=None):
28 |         """
29 | 
30 |         :param string keyword: serach keyword
31 |         :param dict options: search options
32 |         :return:
33 |         """
34 |         providers = self.library.list()
35 |         source_in = [provd.identifier for provd in providers
36 |                      if isinstance(provd, SupportsSongGet)]
37 |         params = {}
38 |         if options is not None:
39 |             type_in = options.pop('type', None)
40 |             source_in = options.pop('source', None)
41 |             source_in_list = []
42 |             if source_in is None:
43 |                 source_in_list = source_in
44 |             elif isinstance(source_in, str):
45 |                 source_in_list = source_in.split(',')
46 |             else:
47 |                 assert isinstance(source_in, list)
48 |                 source_in_list = source_in
49 |             if type_in is not None:
50 |                 params['type_in'] = type_in
51 |             params['source_in'] = source_in_list
52 |             if options:
53 |                 logger.warning('Unknown cmd options: %s', options)
54 |         # TODO: limit output lines
55 |         return list(self.library.search(keyword, **params))
56 | 


--------------------------------------------------------------------------------
/tests/library/test_protocol.py:
--------------------------------------------------------------------------------
 1 | from feeluown.library import (
 2 |     BriefAlbumModel,
 3 |     BriefArtistModel,
 4 |     BriefSongModel, SongModel,
 5 |     BriefUserModel, UserModel,
 6 |     VideoModel,
 7 | )
 8 | from feeluown.library.model_protocol import (
 9 |     BriefAlbumProtocol,
10 |     BriefArtistProtocol,
11 |     BriefSongProtocol, SongProtocol,
12 |     BriefUserProtocol, UserProtocol,
13 |     VideoProtocol,
14 | )
15 | 
16 | 
17 | def test_protocols():
18 |     values = dict(identifier='0', source='test')
19 | 
20 |     brief_album = BriefAlbumModel(**values)
21 |     brief_artist = BriefArtistModel(**values)
22 |     brief_song = BriefSongModel(**values)
23 |     brief_user = BriefUserModel(**values)
24 | 
25 |     song = SongModel(artists=[brief_artist],
26 |                      album=brief_album,
27 |                      title='',
28 |                      duration=0,
29 |                      **values)
30 |     user = UserModel(**values)
31 |     video = VideoModel(title='',
32 |                        artists=[],
33 |                        cover='',
34 |                        duration=0,
35 |                        **values)
36 | 
37 |     # BriefAlbumProtocol
38 |     assert isinstance(brief_album, BriefAlbumProtocol)
39 | 
40 |     # BriefArtistprotocol
41 |     assert isinstance(brief_artist, BriefArtistProtocol)
42 | 
43 |     # BriefSongProtocol
44 |     assert isinstance(brief_song, BriefSongModel)
45 |     assert isinstance(song, BriefSongProtocol)
46 | 
47 |     # SongProtocol
48 |     assert isinstance(song, SongProtocol)
49 | 
50 |     # BriefUserProtocol
51 |     assert isinstance(brief_user, BriefUserProtocol)
52 |     assert isinstance(user, BriefUserProtocol)
53 | 
54 |     # UserProtocol
55 |     assert isinstance(user, UserProtocol)
56 | 
57 |     # VideoProtocol
58 |     assert isinstance(video, VideoProtocol)
59 | 


--------------------------------------------------------------------------------
/.github/workflows/win-release.yml:
--------------------------------------------------------------------------------
 1 | name: Release for windows
 2 | on:
 3 |   push:
 4 |     tags:
 5 |       - v*
 6 |     branches:
 7 |       - master
 8 |   workflow_dispatch:
 9 | 
10 | jobs:
11 |   build-n-publish:
12 |     runs-on: windows-latest
13 |     steps:
14 |     - uses: actions/checkout@master
15 |     - name: Set up Python
16 |       uses: actions/setup-python@v1
17 |       with:
18 |         python-version: "3.11"
19 |     - name: Build
20 |       run: |
21 |         python -m pip install --upgrade pip
22 |         pip install pyinstaller
23 |         pip install pyinstaller-versionfile
24 |         pip install -e .[qt,win32,battery,cookies,ytdl,ai]
25 |     - name: Download mpv-1.dll
26 |       run: |
27 |         choco install curl
28 |         curl -L https://github.com/feeluown/FeelUOwn/releases/download/v3.8/mpv-1.dll -o mpv-1.dll
29 |     - name: Bundle
30 |       run: |
31 |         # Add current working directory to path so that mpv-1.dll can be found.
32 |         $env:path += ";."
33 |         make bundle
34 |     - name: Archive
35 |       run: |
36 |         # List dist to help double check if bundle is ok.
37 |         ls dist/
38 |         powershell Compress-Archive dist/FeelUOwn dist/FeelUOwn-windows.zip
39 |     - name: Upload artifact
40 |       uses: actions/upload-artifact@v4
41 |       with:
42 |         name: FeelUOwn-windows.zip
43 |         path: dist/FeelUOwn-windows.zip
44 |     - name: Upload to release page
45 |       if: startsWith(github.ref, 'refs/tags/v')
46 |       uses: softprops/action-gh-release@v1
47 |       env:
48 |         GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
49 |       with:
50 |         files: dist/FeelUOwn-windows.zip
51 |     - name: Upload to nightly
52 |       if: github.ref == 'refs/heads/master'
53 |       uses: softprops/action-gh-release@v2
54 |       env:
55 |         GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
56 |       with:
57 |         files: dist/FeelUOwn-windows.zip
58 |         tag_name: nightly
59 | 


--------------------------------------------------------------------------------
/tests/gui/widgets/test_progress_slider.py:
--------------------------------------------------------------------------------
 1 | import pytest
 2 | from PyQt6.QtWidgets import QAbstractSlider
 3 | 
 4 | from feeluown.gui.widgets.progress_slider import ProgressSlider
 5 | 
 6 | 
 7 | @pytest.fixture
 8 | def mock_update_player(mocker):
 9 |     return mocker.patch.object(ProgressSlider, 'maybe_update_player_position')
10 | 
11 | 
12 | @pytest.fixture
13 | def slider(qtbot, app_mock):
14 |     slider = ProgressSlider(app_mock)
15 |     app_mock.player.current_media = object()  # An non-empty object.
16 |     app_mock.player.position = 0
17 |     qtbot.addWidget(slider)
18 |     return slider
19 | 
20 | 
21 | def test_basics(qtbot, app_mock):
22 |     slider = ProgressSlider(app_mock)
23 |     qtbot.addWidget(slider)
24 | 
25 | 
26 | def test_action_is_triggered(slider, mock_update_player):
27 |     slider.triggerAction(QAbstractSlider.SliderAction.SliderPageStepAdd)
28 |     assert mock_update_player.called
29 | 
30 | 
31 | def test_maybe_update_player_position(slider):
32 |     slider.maybe_update_player_position(10)
33 | 
34 |     assert slider._app.player.position == 10
35 |     assert slider._app.player.resume.called
36 | 
37 | 
38 | def test_update_total(slider):
39 |     slider.update_total(10)
40 |     assert slider.maximum() == 10
41 | 
42 | 
43 | def test_drag_slider(slider, mock_update_player):
44 |     # Simulate the dragging.
45 |     slider.setSliderDown(True)
46 |     slider.setSliderDown(False)
47 |     assert mock_update_player.called
48 | 
49 | 
50 | def test_media_changed_during_dragging(qtbot, slider, mock_update_player):
51 |     # Simulate the dragging.
52 |     slider.setSliderDown(True)
53 |     slider._dragging_ctx.is_media_changed = True  # Simulate media changed.
54 |     slider.setSliderDown(False)
55 |     assert not mock_update_player.called
56 | 
57 | 
58 | def test_when_player_has_no_media(slider):
59 |     slider._app.player.current_media = None
60 |     slider.triggerAction(QAbstractSlider.SliderAction.SliderPageStepAdd)
61 |     assert not slider._app.player.resume.called
62 | 


--------------------------------------------------------------------------------
/tests/server/test_dslv2.py:
--------------------------------------------------------------------------------
 1 | import pytest
 2 | from feeluown.server.excs import FuoSyntaxError
 3 | from feeluown.server.dslv2 import tokenize, Parser, unparse, parse
 4 | 
 5 | 
 6 | def test_tokenize():
 7 |     tokens = tokenize('search zjl -s=xx')
 8 |     assert tokens == ['search', 'zjl', '-s=xx']
 9 | 
10 |     tokens = tokenize('exec "app.exit()"')
11 |     assert tokens == ['exec', 'app.exit()']
12 | 
13 | 
14 | def test_tokenize_unquoted_source():
15 |     with pytest.raises(FuoSyntaxError):
16 |         tokenize("search zjl -s='xx")
17 | 
18 | 
19 | def test_tokenize_source_with_heredoc():
20 |     with pytest.raises(FuoSyntaxError):
21 |         tokenize("search zjl -s='xx\n'\nx")
22 | 
23 | 
24 | def test_parse():
25 |     req = Parser('search zjl -s=xx').parse()
26 |     assert req.cmd == 'search'
27 |     assert req.cmd_args == ['zjl']
28 |     assert req.cmd_options['source'] == ['xx']
29 | 
30 |     req = Parser('set --format=json').parse()
31 |     assert req.cmd == 'set'
32 |     assert req.options['format'] == 'json'
33 | 
34 | 
35 | def test_parse_with_heredoc():
36 |     req = Parser('''exec <").parse()
57 | 
58 | 
59 | def test_unparse():
60 |     req = Parser('search zjl -s=xx --format=json').parse()
61 |     text = unparse(req)
62 |     assert text == 'search zjl --source=xx --format=json'
63 | 
64 |     req = parse("search 'zjl()'")
65 |     text = unparse(req)
66 |     assert text == "search 'zjl()'"
67 | 


--------------------------------------------------------------------------------
/feeluown/nowplaying/macos.py:
--------------------------------------------------------------------------------
 1 | # pylint: disable=import-error
 2 | from AppKit import NSImage, NSMakeRect, NSCompositingOperationSourceOver
 3 | from Foundation import NSMutableDictionary
 4 | from MediaPlayer import (
 5 |     MPMediaItemArtwork, MPNowPlayingInfoCenter, MPMediaItemPropertyArtwork,
 6 | )
 7 | 
 8 | from feeluown.utils import aio
 9 | 
10 | 
11 | class MacosMixin:
12 | 
13 |     def update_song_metadata(self, meta):
14 |         super().update_song_metadata(meta)
15 | 
16 |         artwork = meta.get('artwork', '')
17 |         artwork_uid = meta.get('uri', artwork)
18 |         self._update_artwork(artwork, artwork_uid)
19 | 
20 |     def _update_artwork(self, artwork, artwork_uid):
21 | 
22 |         async def task():
23 |             b = await self._app.img_mgr.get(artwork, artwork_uid)
24 |             set_artwork(self.info_center, b)
25 | 
26 |         aio.run_afn(task)
27 | 
28 | 
29 | def artwork_from_bytes(b: bytes) -> MPMediaItemArtwork:
30 |     img = NSImage.alloc().initWithData_(b)
31 | 
32 |     def resize(size):
33 |         new = NSImage.alloc().initWithSize_(size)
34 |         new.lockFocus()
35 |         img.drawInRect_fromRect_operation_fraction_(
36 |             NSMakeRect(0, 0, size.width, size.height),
37 |             NSMakeRect(0, 0, img.size().width, img.size().height),
38 |             NSCompositingOperationSourceOver,
39 |             1.0,
40 |         )
41 |         new.unlockFocus()
42 |         return new
43 | 
44 |     art = MPMediaItemArtwork.alloc() \
45 |         .initWithBoundsSize_requestHandler_(img.size(), resize)
46 |     return art
47 | 
48 | 
49 | def set_artwork(info_center: MPNowPlayingInfoCenter, b):
50 |     current_nowplaying_info = info_center.nowPlayingInfo()
51 |     if current_nowplaying_info is None:
52 |         nowplaying_info = NSMutableDictionary.dictionary()
53 |     else:
54 |         nowplaying_info = current_nowplaying_info.mutableCopy()
55 |     if b:
56 |         nowplaying_info[MPMediaItemPropertyArtwork] = artwork_from_bytes(b)
57 |     info_center.setNowPlayingInfo_(nowplaying_info)
58 |     return True
59 | 


--------------------------------------------------------------------------------
/feeluown/utils/aio.py:
--------------------------------------------------------------------------------
 1 | """
 2 | encapsulate asyncio API
 3 | ~~~~~~~~~~~~~~~~~~~~~~~
 4 | 
 5 | Asyncio has added some new API in Python 3.7 and 3.8, e.g.,
 6 | gather, run and create_task. They are recommended in newer version.
 7 | However, FeelUOwn is supposed to be compatible with Python 3.5,
 8 | we will backport some API here. In addition, we create alias
 9 | for some frequently used API.
10 | """
11 | 
12 | import asyncio
13 | import sys
14 | 
15 | 
16 | if sys.version_info >= (3, 7):
17 |     # create_task is temporarily not working properly with quamash
18 |     # https://github.com/harvimt/quamash/issues/116
19 |     create_task = asyncio.ensure_future
20 | else:
21 |     create_task = asyncio.ensure_future
22 | 
23 | #: alias for create_task
24 | spawn = create_task
25 | as_completed = asyncio.as_completed
26 | 
27 | #: sleep is an alias of `asyncio.sleep`.
28 | sleep = asyncio.sleep
29 | 
30 | #: run is an alias of `asyncio.run`.
31 | run = asyncio.run
32 | 
33 | #: wait is an alias of `asyncio.wait`.
34 | wait = asyncio.wait
35 | 
36 | #: gather is an alias of `asyncio.gather`.
37 | gather = asyncio.gather
38 | 
39 | #: wait_for is an alias of `asyncio.wait_for`
40 | wait_for = asyncio.wait_for
41 | 
42 | bg_tasks = set()
43 | 
44 | 
45 | def run_in_executor(executor, func, *args):
46 |     """alias for loop.run_in_executor"""
47 |     loop = asyncio.get_running_loop()
48 |     return loop.run_in_executor(executor, func, *args)
49 | 
50 | 
51 | def run_afn(afn, *args):
52 |     """Alias for create_task
53 | 
54 |     .. versionadded:: 3.7.8
55 |     """
56 |     return create_task(afn(*args))
57 | 
58 | 
59 | def run_afn_ref(afn, *args):
60 |     """Create a background task and keep a reference.
61 | 
62 |     .. versionadded:: 4.1.9
63 |     """
64 |     task = create_task(afn(*args))
65 |     bg_tasks.add(task)
66 |     task.add_done_callback(bg_tasks.discard)
67 |     return task
68 | 
69 | 
70 | def run_fn(fn, *args):
71 |     """Alias for run_in_executor with default executor
72 | 
73 |     .. versionadded:: 3.7.8
74 |     """
75 |     return run_in_executor(None, fn, *args)
76 | 


--------------------------------------------------------------------------------
/feeluown/player/delegate.py:
--------------------------------------------------------------------------------
 1 | import asyncio
 2 | 
 3 | from feeluown.utils import aio
 4 | from feeluown.utils.dispatch import Signal
 5 | from .base_player import AbstractPlayer, State
 6 | 
 7 | 
 8 | class PlayerPositionDelegate:
 9 |     """
10 |     Player notify others through signal when properties are changed. Mpvplayer
11 |     emit signal in non-main thread, and some receivers need to run in main thread.
12 |     From the profile result(#669), we can see that the cross-thread communication
13 |     costs a lot of CPU resources. When the signal emits too frequently, it
14 |     causes obvious performance degration.
15 | 
16 |     This delegate runs in main thread, so the issue is almost solved. In addition,
17 |     it emits position_changed signals with lower frequency by default.
18 | 
19 |     .. versionadded:: 3.8.11
20 |         This class is *experimental*.
21 |     """
22 |     def __init__(self, player: AbstractPlayer, interval=300):
23 |         self._position: float = player.position  # seconds
24 |         self._interval = interval  # microseconds
25 |         self._interval_s = interval / 1000  # seconds
26 | 
27 |         self._player = player
28 |         self._player.seeked.connect(self._update_position_and_emit)
29 |         self._player.state_changed.connect(self.on_state_changed, aioqueue=True)
30 |         self._player.media_changed.connect(self._update_position_and_emit)
31 | 
32 |         self.changed = Signal()
33 |         self._should_stop = False
34 | 
35 |     def initialize(self):
36 |         aio.run_afn(self.start)
37 | 
38 |     async def start(self):
39 |         while not self._should_stop:
40 |             await asyncio.sleep(self._interval_s)
41 |             self._update_position_and_emit()
42 | 
43 |     def stop(self):
44 |         self._should_stop = True
45 | 
46 |     def on_state_changed(self, state):
47 |         # When state is changed, update immediately.
48 |         if state == State.playing:
49 |             self._update_position_and_emit()
50 | 
51 |     def _update_position_and_emit(self, *_):
52 |         self._position = self._player.position
53 |         self.changed.emit(self._position)
54 | 


--------------------------------------------------------------------------------
/feeluown/server/handlers/playlist.py:
--------------------------------------------------------------------------------
 1 | from feeluown.library import ModelType, SupportsPlaylistSongsReader
 2 | from feeluown.library import resolve, reverse
 3 | from .base import AbstractHandler
 4 | 
 5 | 
 6 | class PlaylistHandler(AbstractHandler):
 7 |     cmds = ('add', 'remove', 'list', 'next', 'previous', 'clear',)
 8 | 
 9 |     def handle(self, cmd):  # pylint: disable=inconsistent-return-statements
10 |         # pylint: disable=no-else-return
11 |         if cmd.action == 'add':
12 |             return self.add(cmd.args)
13 |         elif cmd.action == 'remove':
14 |             return self.remove(cmd.args[0].strip())
15 |         elif cmd.action == 'clear':
16 |             return self.clear()
17 |         elif cmd.action == 'list':
18 |             return self.list()
19 |         elif cmd.action == 'next':
20 |             self.playlist.next()
21 |         elif cmd.action == 'previous':
22 |             self.playlist.previous()
23 | 
24 |     def add(self, furi_list):
25 |         playlist = self.playlist
26 |         for furi in furi_list:
27 |             furi = furi.strip()
28 |             obj = resolve(furi)
29 |             if obj is not None:
30 |                 obj_type = obj.meta.model_type
31 |                 if obj_type == ModelType.song:
32 |                     playlist.add(obj)
33 |                 elif obj_type == ModelType.playlist:
34 |                     provider = self.library.get(obj.source)
35 |                     if isinstance(provider, SupportsPlaylistSongsReader):
36 |                         reader = provider.playlist_create_songs_rd(obj)
37 |                         for song in reader:
38 |                             playlist.add(song)
39 |                     # TODO: raise error if it does not support
40 | 
41 |     def remove(self, song_uri):
42 |         # FIXME: a little bit tricky
43 |         for song in self.playlist.list():
44 |             if reverse(song) == song_uri:
45 |                 self.playlist.remove(song)
46 |                 break
47 | 
48 |     def list(self):
49 |         songs = self.playlist.list()
50 |         return songs
51 | 
52 |     def clear(self):
53 |         self.playlist.clear()
54 | 


--------------------------------------------------------------------------------
/docs/source/media_assets_management/index.rst:
--------------------------------------------------------------------------------
 1 | 媒体资源管理
 2 | ===================
 3 | 
 4 | feeluown 一个设计目标是让用户能够高效使用各个音乐平台的合法资源。
 5 | 媒体资源管理定义、规范并统一了各个音乐平台资源的访问接口。
 6 | 
 7 | 音乐库是媒体资源管理子系统的入口。音乐库部分负责管理 feeluown 的音乐资源,
 8 | 包括歌曲、歌手、专辑详情获取,专辑、歌单封面获取等。它主要由几个部分组成:
 9 | 音乐对象模型(*Model*)、音乐提供方(*Provider*)、提供方管理(*Library*)。
10 | 
11 | .. code::
12 | 
13 |     +---------------------------------------------------------------------------+
14 |     |  +---------+                                                              |
15 |     |  | Library |                                                              |
16 |     |  +---------+                +-------------+                               |
17 |     |   |                         | song_get    |                               |
18 |     |   |  +-------------------+  | ...         |                               |
19 |     |   |--| provider(netease) |--| aritst_get  |-----+                         |
20 |     |   |  +-------------------+  | search      |     |     +----------------+  |
21 |     |   |                         | ...         |     |     | BriefSongModel |  |
22 |     |   |                         +-------------+     |     | ...            |  |
23 |     |   |                       +-------------+       +-----|                |  |
24 |     |   |                       | song_get_mv |       |     | SongModel      |  |
25 |     |   |  +-----------------+  | ...         |       |     | ArtistModel    |  |
26 |     |   |--| provider(xiami) |--| album_get   |-------+     | ...            |  |
27 |     |   |  +-----------------+  | search      |             +----------------+  |
28 |     |   |                       | ...         |                                 |
29 |     |   |                       +-------------+                                 |
30 |     |   |--...                                                                  |
31 |     |                                                                           |
32 |     +---------------------------------------------------------------------------+
33 | 
34 | 
35 | .. toctree::
36 |    :maxdepth: 2
37 |    :caption: 目录
38 | 
39 |    library
40 |    provider
41 |    model
42 | 


--------------------------------------------------------------------------------
/examples/macos_nowplaying.py:
--------------------------------------------------------------------------------
 1 | from Foundation import NSRunLoop, NSMutableDictionary, NSObject
 2 | from MediaPlayer import MPRemoteCommandCenter, MPNowPlayingInfoCenter
 3 | from MediaPlayer import (
 4 |     MPMediaItemPropertyTitle, MPMediaItemPropertyArtist,
 5 |     MPMusicPlaybackState, MPMusicPlaybackStatePlaying, MPMusicPlaybackStatePaused,
 6 |     MPNowPlayingInfoPropertyElapsedPlaybackTime, MPMediaItemPropertyPlaybackDuration
 7 | )
 8 | 
 9 | 
10 | class NowPlaying:
11 |     def __init__(self):
12 |         self.cmd_center = MPRemoteCommandCenter.sharedCommandCenter()
13 |         self.info_center = MPNowPlayingInfoCenter.defaultCenter()
14 | 
15 |         cmds = [
16 |             self.cmd_center.togglePlayPauseCommand(),
17 |             self.cmd_center.playCommand(),
18 |             self.cmd_center.pauseCommand(),
19 |         ]
20 | 
21 |         for cmd in cmds:
22 |             cmd.addTargetWithHandler_(self._create_handler(cmd))
23 | 
24 |         self.update_info()
25 | 
26 |     def update_info(self):
27 |         nowplaying_info = NSMutableDictionary.dictionary()
28 |         nowplaying_info[MPMediaItemPropertyTitle] = "title"
29 |         nowplaying_info[MPMediaItemPropertyArtist] = "artist"
30 |         nowplaying_info[MPNowPlayingInfoPropertyElapsedPlaybackTime] = 0
31 |         nowplaying_info[MPMediaItemPropertyPlaybackDuration] = 100
32 |         self.info_center.setNowPlayingInfo_(nowplaying_info)
33 |         self.info_center.setPlaybackState_(MPMusicPlaybackStatePlaying)
34 | 
35 | 
36 |     def _create_handler(self, cmd):
37 | 
38 |         def handle(event):
39 |             if event.command() == self.cmd_center.pauseCommand():
40 |                 self.info_center.setPlaybackState_(MPMusicPlaybackStatePaused)
41 |             elif event.command() == self.cmd_center.playCommand():
42 |                 self.info_center.setPlaybackState_(MPMusicPlaybackStatePlaying)
43 |             return 0
44 | 
45 |         return handle
46 | 
47 | 
48 | def runloop():
49 |     """
50 |     HELP: This function can't be called in non-main thread.
51 |     """
52 |     nowplaying = NowPlaying()
53 |     NSRunLoop.mainRunLoop().run()
54 | 
55 | 
56 | runloop()
57 | 


--------------------------------------------------------------------------------
/feeluown/serializers/__init__.py:
--------------------------------------------------------------------------------
 1 | from .base import SerializerError, DeserializerError
 2 | 
 3 | # format Serializer mapping, like::
 4 | #
 5 | # {
 6 | #    'json':  JsonSerializer,
 7 | #    'plain': PlainSerializer
 8 | # }
 9 | _MAPPING = {}
10 | _DE_MAPPING = {}
11 | 
12 | 
13 | def register_serializer(type_, serializer_cls):
14 |     _MAPPING[type_] = serializer_cls
15 | 
16 | 
17 | def register_deserializer(type_, deserializer_cls):
18 |     _DE_MAPPING[type_] = deserializer_cls
19 | 
20 | 
21 | def get_serializer(format_):
22 |     if not _MAPPING:
23 |         register_serializer('plain', PlainSerializer)
24 |         register_serializer('json', JsonSerializer)
25 |         register_serializer('python', PythonSerializer)
26 |     if format_ not in _MAPPING:
27 |         raise SerializerError(f"Serializer for format:{format_} not found")
28 |     return _MAPPING.get(format_)
29 | 
30 | 
31 | def get_deserializer(format_: str):
32 |     if not _DE_MAPPING:
33 |         register_deserializer('python', PythonDeserializer)
34 |     if format_ not in _DE_MAPPING:
35 |         raise DeserializerError(f"Deserializer for format:{format_} not found")
36 |     return _DE_MAPPING[format_]
37 | 
38 | 
39 | def serialize(format_, obj, **options):
40 |     """serialize python object defined in feeluown package
41 | 
42 |     :raises SerializerError:
43 | 
44 |     Usage::
45 | 
46 |         serialize('plain', song, as_line=True)
47 |         serialize('plain', song, as_line=True, fetch=True)
48 |         serialize('json', songs, indent=4)
49 |         serialize('json', songs, indent=4, fetch=True)
50 |         serialize('json', providers)
51 |     """
52 |     serializer = get_serializer(format_)(**options)
53 |     return serializer.serialize(obj)
54 | 
55 | 
56 | def deserialize(format_, obj, **options):
57 |     deserializer = get_deserializer(format_)(**options)
58 |     return deserializer.deserialize(obj)
59 | 
60 | 
61 | from .base import SerializerMeta, SimpleSerializerMixin  # noqa
62 | from .plain import PlainSerializer  # noqa
63 | from .json_ import JsonSerializer  # noqa
64 | from .python import PythonSerializer, PythonDeserializer  # noqa
65 | from .objs import *  # noqa
66 | 


--------------------------------------------------------------------------------
/tests/library/test_library.py:
--------------------------------------------------------------------------------
 1 | import pytest
 2 | 
 3 | from feeluown.library import (
 4 |     ModelType, BriefAlbumModel, BriefSongModel, Provider, Media,
 5 |     SimpleSearchResult, Quality
 6 | )
 7 | 
 8 | 
 9 | @pytest.mark.asyncio
10 | async def test_library_a_search(library):
11 |     result = [x async for x in library.a_search('xxx')][0]
12 |     assert result.q == 'xxx'
13 | 
14 | 
15 | def test_library_model_get(library, ekaf_provider, ekaf_album0):
16 |     album = library.model_get(ekaf_provider.identifier,
17 |                               ModelType.album,
18 |                               ekaf_album0.identifier)
19 |     assert album.identifier == ekaf_album0.identifier
20 | 
21 | 
22 | def test_library_model_upgrade(library, ekaf_provider, ekaf_album0):
23 |     album = BriefAlbumModel(identifier=ekaf_album0.identifier,
24 |                             source=ekaf_provider.identifier)
25 |     album = library._model_upgrade(album)
26 |     assert album.name == ekaf_album0.name
27 | 
28 | 
29 | def test_prepare_mv_media(library, ekaf_brief_song0):
30 |     media = library.song_prepare_mv_media(ekaf_brief_song0, '<<<')
31 |     assert media.url != ''  # media url is valid(not empty)
32 | 
33 | 
34 | @pytest.mark.asyncio
35 | async def test_library_a_list_song_standby_v2(library):
36 | 
37 |     class GoodProvider(Provider):
38 |         @property
39 |         def identifier(self):
40 |             return 'good'
41 | 
42 |         @property
43 |         def name(self):
44 |             return 'good'
45 | 
46 |         def song_list_quality(self, _):
47 |             return [Quality.Audio.hq]
48 | 
49 |         def song_get_media(self, _, __):
50 |             return Media('good.mp3')
51 | 
52 |         def search(self, *_, **__):
53 |             return SimpleSearchResult(
54 |                 q='',
55 |                 songs=[BriefSongModel(identifier='1', source=self.identifier)]
56 |             )
57 | 
58 |     library.register(GoodProvider())
59 |     song = BriefSongModel(identifier='1', title='', source='xxx')
60 |     song_media_list = await library.a_list_song_standby_v2(song)
61 |     assert song_media_list
62 |     assert song_media_list[0][1].url == 'good.mp3'
63 | 


--------------------------------------------------------------------------------
/feeluown/serializers/typename.py:
--------------------------------------------------------------------------------
 1 | from collections import defaultdict
 2 | from typing import Any
 3 | from unittest.mock import Mock
 4 | 
 5 | from feeluown.app import App
 6 | from feeluown.player import Metadata
 7 | from feeluown.library import (
 8 |     BaseModel,
 9 |     SongModel,
10 |     ArtistModel,
11 |     AlbumModel,
12 |     PlaylistModel,
13 |     UserModel,
14 |     BriefSongModel,
15 |     BriefArtistModel,
16 |     BriefAlbumModel,
17 |     BriefPlaylistModel,
18 |     BriefUserModel,
19 | )
20 | 
21 | model_cls_list = [
22 |     BaseModel,
23 |     SongModel,
24 |     ArtistModel,
25 |     AlbumModel,
26 |     PlaylistModel,
27 |     UserModel,
28 |     BriefSongModel,
29 |     BriefArtistModel,
30 |     BriefAlbumModel,
31 |     BriefPlaylistModel,
32 |     BriefUserModel,
33 | ]
34 | 
35 | _sys_typenames = {  # for unittest.
36 |     'unittest.mock.Mock': Mock
37 | }
38 | _typenames = {
39 |     'player.Metadata': Metadata,
40 |     'app.App': App,  # TODO: remove this
41 | }
42 | for model_cls in model_cls_list:
43 |     _typenames[f'library.{model_cls.__name__}'] = model_cls
44 | typenames = {f'feeluown.{k}': v for k, v in _typenames.items()}
45 | typenames.update(_sys_typenames)
46 | 
47 | r_typenames = defaultdict(list)
48 | for k, v in typenames.items():
49 |     r_typenames[v].append(k)
50 | r_typenames = dict(r_typenames)
51 | 
52 | 
53 | def get_type_by_name(name: str):
54 |     return typenames.get(name, None)
55 | 
56 | 
57 | def get_names_by_type(type_: Any):
58 |     # try except so that performance is not affected.
59 |     try:
60 |         return r_typenames[type_]
61 |     except KeyError:
62 |         if type_.__module__ == 'unittest.mock':
63 |             return ['unittest.mock.Mock']
64 |         return []
65 | 
66 | 
67 | def attach_typename(method):
68 | 
69 |     def wrapper(this, obj, **kwargs):
70 |         result = method(this, obj, **kwargs)
71 |         if isinstance(result, dict):
72 |             typenames = get_names_by_type(type(obj))
73 |             if typenames:
74 |                 result['__type__'] = typenames[0]
75 |             else:
76 |                 result['__type__'] = 'to_be_added__pr_is_welcome'
77 |         return result
78 | 
79 |     return wrapper
80 | 


--------------------------------------------------------------------------------
/feeluown/version.py:
--------------------------------------------------------------------------------
 1 | import asyncio
 2 | import logging
 3 | from functools import partial
 4 | from packaging import version
 5 | 
 6 | import requests
 7 | from requests.exceptions import ConnectionError, Timeout
 8 | 
 9 | from feeluown import __version__
10 | 
11 | logger = logging.getLogger(__name__)
12 | 
13 | 
14 | class VersionManager(object):
15 | 
16 |     def __init__(self, app):
17 |         self._app = app
18 | 
19 |         self._app.started.connect(self.on_app_started)
20 | 
21 |     def on_app_started(self, *args):
22 |         loop = asyncio.get_running_loop()
23 |         loop.call_later(
24 |             10,
25 |             partial(loop.create_task, self.check_release()))
26 | 
27 |     async def check_release(self):
28 |         loop = asyncio.get_event_loop()
29 | 
30 |         logger.info('正在检测更新...')
31 |         try:
32 |             resp = await loop.run_in_executor(
33 |                 None,
34 |                 partial(requests.get, 'https://pypi.org/pypi/feeluown/json', timeout=2)
35 |             )
36 |         except (ConnectionError, Timeout) as e:
37 |             logger.warning(e)
38 |             logger.warning('检查更新失败')
39 |         else:
40 |             rv = resp.json()
41 |             latest = version.parse(rv['info']['version'])
42 |             current = version.parse(__version__)
43 |             if latest > current:
44 |                 msg = '检测到新版本 %s,当前版本为 %s' % (latest, current)
45 |                 logger.warning(msg)
46 |                 if self._app.mode & self._app.GuiMode:
47 |                     self._app.show_msg(msg)
48 |             else:
49 |                 logger.info('当前已经是最新版本')
50 |                 if self._app.mode & self._app.GuiMode:
51 |                     self._app.show_msg(f'当前已经是最新版本: {latest}')
52 | 
53 | 
54 | if __name__ == '__main__':
55 |     """
56 |     测试 VersionManager 基本行为
57 |     """
58 | 
59 |     import logging
60 |     logging.basicConfig()
61 |     logger.setLevel(logging.DEBUG)
62 | 
63 |     class App(object):
64 |         GuiMode = 0x10
65 |         mode = 0x01
66 | 
67 |     app = App()
68 |     mgr = VersionManager(app)
69 |     loop = asyncio.get_event_loop()
70 |     loop.run_until_complete(loop.create_task(mgr.check_release()))
71 | 


--------------------------------------------------------------------------------
/docs/source/contributing.rst:
--------------------------------------------------------------------------------
 1 | 贡献力量
 2 | =============
 3 | 
 4 | 各位老师、同鞋,非常高兴你能点开这个文档~
 5 | 
 6 | 在说怎样贡献代码之前,我们先啰嗦一下 FeelUOwn 项目的 **主要目标** 。
 7 | 
 8 | 项目背景及目标
 9 | -------------------
10 | 
11 | FeelUOwn 这个项目从 2015 年初开发到现在,已经 4 年有余,
12 | 从功能角度看,它已经具备最基本的功能,如本地歌曲扫瞄、歌曲播放、MV 播放、歌词。
13 | 基本模块也比较齐全,配置、插件、音乐库、远程控制等。
14 | 
15 | 一开始,FeelUOwn 解决的问题是 Linux 上听在线音乐难。和现在百花齐放的生态不一样,
16 | 当年(=。=),Linux 上大部分播放器都只支持播放本地音乐,并且经常出现乱码现象。
17 | 我了解到的当时能听在线音乐的播放器有 deepin-music, 虾米电台等播放器。所以,
18 | 自己选择开发这个软件,更多历史请看这几篇博客 文章1_ 、 文章2_ 。
19 | 
20 | 如今,当时的问题已经不复存在,FeelUOwn 主要目标之一是提升用户听音乐的体验。
21 | 具体来说,主要体现以下几个方面:
22 | 
23 | 1. 让用户听任何想听的音乐
24 | 2. 辅助用户能够发现、享受音乐
25 | 
26 | 另外,FeelUOwn 也尽力成为一个程序员最友好的音乐播放器。这主要体现在这些点:
27 | 
28 | 1. 可扩展性强
29 | 2. 项目工程化
30 | 3. 尊重 Unix 习俗
31 | 
32 | 可以做哪些贡献?
33 | --------------------------
34 | 
35 | 我们为 FeelUOwn 项目做贡献时,也都会围绕这些目标来进行。
36 | 
37 | 首先,如果大家自己觉得播放器缺少了功能或者特性,可以提 Issue,有好的想法,
38 | 也可以提,我们非常期待大家的建议和想法。如果大家自己有需求,并且自己有能力动手实现,
39 | 我们也建议先在 Issue 上或者交流群进行简单讨论,然后进行代码实现。
40 | 
41 | 用户会通过 Issue 或者交流群来提出的需求或想法,我们会把它们都收集,
42 | 记录在 `FeelUOwn GitHub 项目`_ 中,其中有大大小小的 TODO,
43 | 大家如果对某个 TODO 有兴趣,就可以进行相应的开发,
44 | 开发之前最好可以在 Telegram 交流群中吼一声,或者和管理员(目前为 @cosven) 说一声,
45 | 这样,这个 TODO 会被移动到 In progress 这一栏中去,避免大家做重复的工作。
46 | 
47 | 除了功能方面,我们也特别欢迎大家对项目代码进行改进、让项目更加工程化。
48 | 目前,FeelUOwn 有一些设计不优雅、或者性能较差的代码,一部分是我们可以发现的,
49 | 我们已经将其标记为 `FIXME` / `TODO` / `DOUBT` ;另外,有些设计不好的地方,
50 | 我们还没有特别明确(比如 PyQt 在 FeelUOwn 中的使用),大家如果对这些问题有兴趣,
51 | 欢迎 PR!另外,工程化方面,FeelUOwn 的文档、单元测试、打包等都可以更加完善,
52 | 欢迎感兴趣的朋友根据自己的爱好进行选择。
53 | 
54 | 如何做贡献?
55 | ----------------
56 | 
57 | 对于一些小的 bugfix, feature 开发, 文档补全等,大家可以自己动手,然后 PR。
58 | 对于大的特性开发或者改动,建议大家先将自己的想法整理成文字,提在 Issue 上,
59 | 和大家同步并讨论,之后再动手开发。
60 | 
61 | 如果需要进行修改代码(包括文档等),可以参考 :doc:`dev_quickstart` ,
62 | 代码风格请参考 :doc:`coding_style` ,一些 FeelUOwn
63 | 架构设计相关的决策,可以参考 :doc:`arch` 和 :doc:`api` 等文档。
64 | 
65 | 最后值得一提的是,我们有一个开发者/用户交流群(邀请链接在 README_ 中),大家可以加入群里,
66 | 有任何 *相关* 或者 `有意义的问题`_ ,都可以在群里进行讨论,有任何疑问,
67 | 也可以在群里沟通。感谢大家为 FeelUOwn 做出的贡献!
68 | 
69 | .. _文章1: http://cosven.me/blogs/57
70 | .. _文章2: http://cosven.me/blogs/58
71 | .. _FeelUOwn GitHub 项目: https://github.com/cosven/FeelUOwn/projects/5
72 | .. _有意义的问题: https://zh.wikipedia.org/wiki/%E6%8F%90%E9%97%AE%E7%9A%84%E6%99%BA%E6%85%A7
73 | .. _README: https://github.com/cosven/feeluown
74 | 


--------------------------------------------------------------------------------
/tests/serializers/test_serializers.py:
--------------------------------------------------------------------------------
 1 | from feeluown.app import App
 2 | from feeluown.player import Player, Playlist
 3 | from feeluown.serializers import serialize
 4 | from feeluown.library import SongModel, SimpleSearchResult, AlbumModel
 5 | from feeluown.player import Metadata
 6 | 
 7 | 
 8 | def test_serialize_app(mocker):
 9 |     app = mocker.Mock(spec=App)
10 |     app.task_mgr = mocker.Mock()
11 |     app.live_lyric = mocker.Mock()
12 |     app.live_lyric.current_sentence = ''
13 |     player = Player()
14 |     app.player = player
15 |     app.playlist = Playlist(app)
16 |     for format in ('plain', 'json'):
17 |         serialize(format, app, brief=False)
18 |         serialize(format, app, fetch=True)
19 |     player.shutdown()
20 | 
21 | 
22 | def test_serialize_metadata():
23 |     metadata = Metadata({'title': 'hello world'})
24 |     js = serialize('python', metadata)
25 |     assert js['__type__'].endswith('player.Metadata')
26 | 
27 | 
28 | def test_serialize_model():
29 |     song = SongModel(
30 |         identifier='1',
31 |         title='hello',
32 |         album=AlbumModel(
33 |             identifier='1',
34 |             name='album',
35 |             cover='',
36 |             artists=[],
37 |             songs=[],
38 |             description='',
39 |         ),
40 |         artists=[],
41 |         duration=0
42 |     )
43 |     song_js = serialize('python', song)
44 |     assert song_js['identifier'] == '1'
45 |     assert song_js['title'] == 'hello'
46 |     serialize('plain', song)  # should not raise error
47 | 
48 |     song_js = serialize('python', song)
49 |     assert song_js['identifier'] == '1'
50 |     assert song_js['uri'] == 'fuo://dummy/songs/1'
51 |     assert song_js['__type__'] == 'feeluown.library.SongModel'
52 |     assert song_js['album']['__type__'] == 'feeluown.library.AlbumModel'
53 |     assert song_js['album']['uri'] == 'fuo://dummy/albums/1'
54 |     assert song_js['album']['type_'] == 'standard'
55 | 
56 | 
57 | def test_serialize_search_result():
58 |     song = SongModel(identifier='1', title='', artists=[], duration=0)
59 |     result = SimpleSearchResult(q='', songs=[song])
60 |     d = serialize('python', result)
61 |     assert d['songs'][0]['identifier'] == '1'
62 |     serialize('plain', result)  # should not raise error
63 | 


--------------------------------------------------------------------------------
/feeluown/serializers/_plain_formatter.py:
--------------------------------------------------------------------------------
 1 | from bisect import bisect
 2 | from string import Formatter
 3 | 
 4 | 
 5 | widths = [
 6 |     (0, 1),
 7 |     (126, 1), (159, 0), (687, 1), (710, 0), (711, 1),
 8 |     (727, 0), (733, 1), (879, 0), (1154, 1), (1161, 0),
 9 |     (4347, 1), (4447, 2), (7467, 1), (7521, 0), (8369, 1),
10 |     (8426, 0), (9000, 1), (9002, 2), (11021, 1), (12350, 2),
11 |     (12351, 1), (12438, 2), (12442, 0), (19893, 2), (19967, 1),
12 |     (55203, 2), (63743, 1), (64106, 2), (65039, 1), (65059, 0),
13 |     (65131, 2), (65279, 1), (65376, 2), (65500, 1), (65510, 2),
14 |     (120831, 1), (262141, 2), (1114109, 1),
15 | ]
16 | points = [w[0] for w in widths]
17 | 
18 | 
19 | def char_len(c):
20 |     ord_code = ord(c)
21 |     if ord_code == 0xe or ord_code == 0xf:
22 |         return 0
23 |     i = bisect(points, ord_code)
24 |     return widths[i][1]
25 | 
26 | 
27 | def _fit_text(text, length, filling=True):
28 |     assert 80 >= length >= 5
29 | 
30 |     text_len = 0
31 |     len_index_map = {}
32 |     for i, c in enumerate(text):
33 |         text_len += char_len(c)
34 |         len_index_map[text_len] = i
35 | 
36 |     if text_len <= length:
37 |         if filling:
38 |             return text + (length - text_len) * ' '
39 |         return text
40 | 
41 |     remain = length - 1
42 |     if remain in len_index_map:
43 |         return text[:(len_index_map[remain] + 1)] + '…'
44 |     else:
45 |         return text[:(len_index_map[remain - 1] + 1)] + ' …'
46 | 
47 | 
48 | class WideFormatter(Formatter):
49 |     """
50 |     Custom string formatter that handles new format parameters:
51 |     '_': _fit_text(*, filling=False)
52 |     '+': _fit_text(*, filling=True)
53 |     """
54 | 
55 |     def format(self, format_string, *args, **kwargs):
56 |         return super().vformat(format_string, args, kwargs)
57 | 
58 |     def format_field(self, value, format_spec):
59 |         if value is None:
60 |             value = 'null'
61 |         fmt_type = format_spec[0] if format_spec else None
62 |         if fmt_type == "_":
63 |             return _fit_text(value, int(format_spec[1:]), filling=False)
64 |         elif fmt_type == "+":
65 |             return _fit_text(value, int(format_spec[1:]), filling=True)
66 |         return super().format_field(value, format_spec)
67 | 


--------------------------------------------------------------------------------
/feeluown/entry_points/base.py:
--------------------------------------------------------------------------------
 1 | import logging
 2 | import os
 3 | import sys
 4 | import warnings
 5 | 
 6 | from feeluown import logger_config
 7 | from feeluown.app import App
 8 | from feeluown.consts import (
 9 |     HOME_DIR, USER_PLUGINS_DIR, DATA_DIR,
10 |     CACHE_DIR, USER_THEMES_DIR, SONG_DIR, COLLECTIONS_DIR
11 | )
12 | 
13 | logger = logging.getLogger(__name__)
14 | 
15 | 
16 | def ensure_dirs():
17 |     for d in (HOME_DIR,
18 |               DATA_DIR,
19 |               USER_THEMES_DIR,
20 |               USER_PLUGINS_DIR,
21 |               CACHE_DIR,
22 |               SONG_DIR,
23 |               COLLECTIONS_DIR):
24 |         if not os.path.exists(d):
25 |             os.mkdir(d)
26 | 
27 | 
28 | def setup_config(args, config):
29 |     config.DEBUG = args.debug or config.DEBUG
30 |     config.VERBOSE = args.verbose or config.VERBOSE
31 |     config.MPV_AUDIO_DEVICE = args.mpv_audio_device or config.MPV_AUDIO_DEVICE
32 |     config.LOG_TO_FILE = bool(args.log_to_file or config.LOG_TO_FILE or
33 |                               os.getenv('FUO_LOG_TO_FILE'))
34 | 
35 |     if args.cmd is not None:
36 |         config.MODE = App.CliMode
37 |         # Always log to file in cli mode because logs may pollute the output.
38 |         config.LOG_TO_FILE = True
39 |     else:
40 |         if not args.no_window:
41 |             try:
42 |                 import PyQt6  # noqa, pylint: disable=unused-import
43 |             except ImportError:
44 |                 logger.warning('PyQt6 is not installed, fallback to daemon mode.')
45 |             else:
46 |                 try:
47 |                     from feeluown.utils.compat import QEventLoop  # noqa
48 |                 except ImportError:
49 |                     logger.warning('no QEventLoop, fallback to daemon mode.')
50 |                 else:
51 |                     config.MODE |= App.GuiMode
52 |         if not args.no_server:
53 |             config.MODE |= App.DaemonMode
54 | 
55 | 
56 | def setup_logger(config):
57 |     if config.DEBUG:
58 |         verbose = 3
59 |     else:
60 |         verbose = config.VERBOSE
61 |     logger_config(verbose=verbose, to_file=config.LOG_TO_FILE)
62 |     # Show deprecation warning when user does not set it.
63 |     if not sys.warnoptions and verbose >= 2:
64 |         warnings.simplefilter('default', DeprecationWarning)
65 | 


--------------------------------------------------------------------------------
/tests/test_plugin.py:
--------------------------------------------------------------------------------
 1 | from importlib.util import spec_from_file_location, module_from_spec
 2 | 
 3 | import pytest
 4 | 
 5 | from feeluown.config import Config
 6 | from feeluown.plugin import Plugin, PluginsManager
 7 | 
 8 | 
 9 | foo_py_content = """
10 | __alias__ = 'FOO'
11 | __desc__ = ''
12 | __version__ = '0.1'
13 | 
14 | 
15 | def init_config(config):
16 |     config.deffield('VERBOSE', type_=int, default=0)
17 | """
18 | 
19 | 
20 | @pytest.fixture
21 | def foo_module(tmp_path):
22 |     pyfile = tmp_path / 'foo.py'
23 |     pyfile.touch()
24 |     with pyfile.open('w') as f:
25 |         f.write(foo_py_content)
26 |     spec = spec_from_file_location('foo', pyfile)
27 |     assert spec is not None
28 |     module = module_from_spec(spec)
29 |     spec.loader.exec_module(module)
30 |     return module
31 | 
32 | 
33 | @pytest.fixture
34 | def foo_plugin(foo_module):
35 |     return Plugin.create(foo_module)
36 | 
37 | 
38 | @pytest.fixture
39 | def plugin_mgr():
40 |     return PluginsManager()
41 | 
42 | 
43 | def test_plugin_init_config(foo_plugin):
44 |     config = Config()
45 |     foo_plugin.init_config(config)
46 |     # `config.foo` should be created and foo.VERBOSE should be equal to 0.
47 |     assert config.foo.VERBOSE == 0
48 | 
49 | 
50 | def test_plugin_manager_init_plugins_config(plugin_mgr, foo_module, mocker):
51 |     # When one plugin init_config fails, it should not affect others.
52 | 
53 |     def init_config_raise(*args, **kwargs): raise Exception('xxx')
54 | 
55 |     invalid_plugin = mocker.Mock()
56 |     invalid_plugin.init_config = init_config_raise
57 | 
58 |     config = Config()
59 |     plugin_mgr._plugins['xxx'] = invalid_plugin
60 |     plugin_mgr.load_plugin_from_module(foo_module)
61 |     plugin_mgr.init_plugins_config(config)
62 |     assert config.foo.VERBOSE == 0
63 | 
64 | 
65 | def test_plugin_manager_enable_plugin(plugin_mgr, foo_module, app_mock, mocker):
66 |     mock_init_config = mocker.patch.object(Plugin, 'init_config')
67 |     mock_enable = mocker.patch.object(Plugin, 'enable')
68 |     plugin_mgr.load_plugin_from_module(foo_module)
69 |     plugin_mgr.init_plugins_config(Config())
70 |     plugin_mgr.enable_plugins(app_mock)
71 |     # The `init_config` and `enable` function should be called.
72 |     assert mock_init_config.called
73 |     assert mock_enable.called
74 | 


--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
 1 | ## FeelUOwn - feel your own
 2 | 
 3 | [![Documentation Status](https://readthedocs.org/projects/feeluown/badge/?version=latest)](http://feeluown.readthedocs.org)
 4 | [![Build Status](https://github.com/feeluown/feeluown/actions/workflows/build.yml/badge.svg?branch=master)](https://github.com/feeluown/FeelUOwn)
 5 | [![Coverage Status](https://coveralls.io/repos/github/feeluown/FeelUOwn/badge.svg)](https://coveralls.io/github/feeluown/FeelUOwn)
 6 | [![PyPI](https://img.shields.io/pypi/v/feeluown.svg)](https://pypi.python.org/pypi/feeluown)
 7 | [![python](https://img.shields.io/pypi/pyversions/feeluown.svg)](https://pypi.python.org/pypi/feeluown)
 8 | 
 9 | FeelUOwn 是一个稳定、用户友好以及高度可定制的音乐播放器。
10 | 
11 | [![macOS 效果预览](https://github.com/user-attachments/assets/6d96c655-e35b-46d8-aaec-4d4dc202347f)](https://www.bilibili.com/video/av46787694/)
12 | 
13 | ### 特性
14 | 
15 | - 稳定、易用:
16 |   - 一键安装,各流行平台均有打包(如 Arch Linux, Windows, macOS 等)
17 |   - 有各媒体资源平台的插件,充分且合理的利用全网免费资源(如 Youtube Music 等)
18 |   - 基础功能完善,桌面歌词、资源智能替换、多音质选择、nowplaying 协议等
19 |   - 核心模块有较好的测试覆盖、核心接口保持较好的向后兼容
20 |   - 大模型加持:AI 电台、自然语言转歌单等
21 | - 可玩性强:
22 |   - 提供基于 TCP 的交互控制协议
23 |   - 基于文本的歌单,方便与朋友分享、设备之间同步
24 |   - 支持基于 Python 的配置文件 `.fuorc`,类似 `.vimrc` 和 `.emacs`
25 | 
26 | ### 快速试用
27 | 
28 | 使用系统包管理器一键安装 FeelUOwn 及其扩展吧!
29 | 
30 | 对于 Arch Linux 和 macOS,你可以分别使用如下方式安装:
31 | ```sh
32 | # Arch Linux
33 | yay -S feeluown          # 安装稳定版,最新版的包名为 feeluown-git
34 | yay -S feeluown-netease  # 按需安装其它扩展
35 | yay -S feeluown-ytmusic
36 | yay -S feeluown-bilibili
37 | 
38 | # macOS(推荐优先尝试在 Release 页面下载打包好的安装包!)
39 | brew tap feeluown/feeluown
40 | brew install feeluown --with-battery # 安装 FeelUOwn 以及扩展
41 | feeluown genicon                     # 在桌面生成 FeelUOwn 图标
42 | ```
43 | 
44 | Windows 和 macOS 用户可以在 Release 页面下载预打包好的二进制。
45 | Gentoo, NixOS, Debian, openSUSE 等 Linux 发行版也支持使用其系统包管理器安装!
46 | 详情可以参考文档:https://feeluown.readthedocs.io/ ,
47 | 也欢迎你加入开发者/用户[交流群](https://t.me/joinchat/H7k12hG5HYsGy7RVvK_Dwg)。
48 | 
49 | ### 免责声明
50 | 
51 | FeelUown(以下简称“本软件”)是一个个人媒体资源播放工具。本软件提供的所有功能和资料
52 | 不得用于任何商业用途。用户可以自由选择是否使用本产品提供的软件。如果用户下载、安装、
53 | 使用本软件,即表明用户信任该软件作者,软件作者对任何原因在使用本软件时可能对用户自己
54 | 或他人造成的任何形式的损失和伤害不承担责任。
55 | 
56 | 任何单位或个人认为通过本软件提供的功能可能涉嫌侵犯其合法权益,应该及时向 feeluown
57 | 组织书面反馈,并提供身份证明、权属证明及详细侵权情况证明,在收到上述法律文件后,
58 | feeluown 组织将会尽快移除被控侵权内容。(联系方式: yinshaowen241 [at] gmail [dot] com )
59 | 


--------------------------------------------------------------------------------
/feeluown/gui/widgets/accordion.py:
--------------------------------------------------------------------------------
 1 | from PyQt6.QtWidgets import QWidget, QVBoxLayout, QLabel, QHBoxLayout
 2 | 
 3 | from feeluown.gui.widgets.textbtn import TextButton
 4 | from feeluown.gui.helpers import ClickableMixin
 5 | 
 6 | 
 7 | class ClickableHeader(ClickableMixin, QWidget):
 8 |     btn_text_fold = "△"
 9 |     btn_text_unfold = "▼"
10 | 
11 |     def __init__(self, header, checked=False, *args, **kwargs):
12 |         super().__init__(*args, **kwargs)
13 | 
14 |         self._is_checked = False
15 | 
16 |         self.inner_header = header
17 |         self.btn = TextButton(self._get_btn_text(self._is_checked))
18 | 
19 |         self._layout = QHBoxLayout(self)
20 |         self._layout.setContentsMargins(0, 0, 0, 0)
21 |         self._layout.setSpacing(0)
22 |         self._layout.addWidget(self.inner_header)
23 |         self._layout.addStretch(0)
24 |         self._layout.addWidget(self.btn)
25 | 
26 |         self.btn.clicked.connect(self.clicked.emit)
27 |         self.clicked.connect(self.toggle)
28 | 
29 |     def toggle(self):
30 |         self._is_checked = not self._is_checked
31 |         self.btn.setText(self._get_btn_text(self._is_checked))
32 | 
33 |     def _get_btn_text(self, checked):
34 |         return self.btn_text_unfold if checked else self.btn_text_fold
35 | 
36 | 
37 | class Accordion(QWidget):
38 |     """
39 | 
40 |     TODO: should be able to customize spacing.
41 |     TODO: API will be changed.
42 |     """
43 | 
44 |     def __init__(self, *args, **kwargs):
45 |         super().__init__(*args, **kwargs)
46 | 
47 |         self._layout = QVBoxLayout(self)
48 |         self._layout.setContentsMargins(0, 0, 0, 0)
49 |         self._layout.setSpacing(0)
50 | 
51 |     def add_section(
52 |         self,
53 |         header: QLabel,
54 |         content: QWidget,
55 |         header_spacing: int,
56 |         section_spacing: int,
57 |     ):
58 |         def toggle_content():
59 |             if content.isVisible():
60 |                 content.hide()
61 |             else:
62 |                 content.show()
63 | 
64 |         clickable_header = ClickableHeader(header, not content.isVisible())
65 |         clickable_header.clicked.connect(toggle_content)
66 | 
67 |         self._layout.addWidget(clickable_header)
68 |         self._layout.addSpacing(header_spacing)
69 |         self._layout.addWidget(content)
70 |         self._layout.addSpacing(section_spacing)
71 | 


--------------------------------------------------------------------------------
/feeluown/library/text2song.py:
--------------------------------------------------------------------------------
 1 | import json
 2 | 
 3 | from .models import BriefSongModel, ModelState, fmt_artists_names
 4 | from feeluown.utils.utils import elfhash
 5 | 
 6 | 
 7 | class AnalyzeError(Exception):
 8 |     pass
 9 | 
10 | 
11 | def create_dummy_brief_song(title, artists_name):
12 |     identifier = elfhash(f'{title}-{artists_name}'.encode('utf-8'))
13 |     return BriefSongModel(
14 |         source='dummy',
15 |         identifier=identifier,
16 |         title=title,
17 |         artists_name=artists_name,
18 |         state=ModelState.not_exists,
19 |     )
20 | 
21 | 
22 | def analyze_text(text):
23 |     def json_fn(each):
24 |         try:
25 |             return each['title'], fmt_artists_names(each['artists'])
26 |         except KeyError:
27 |             return None
28 | 
29 |     def line_fn(line):
30 |         parts = line.split('|')
31 |         if len(parts) == 2 and parts[0]:  # title should not be empty
32 |             return (parts[0], parts[1])
33 |         return None
34 | 
35 |     try:
36 |         data = json.loads(text)
37 |     except json.JSONDecodeError:
38 |         lines = text.strip().split('\n')
39 |         if lines:
40 |             first_line = lines[0].strip()
41 |             if first_line in ('---', '==='):
42 |                 parse_each_fn = line_fn
43 |                 items = [each.strip() for each in lines[1:] if each.strip()]
44 |             elif first_line == '```json':
45 |                 try:
46 |                     items = json.loads(text[7:-3])
47 |                 except json.JSONDecodeError:
48 |                     raise AnalyzeError('invalid JSON content inside code block')
49 |                 parse_each_fn = json_fn
50 |             else:
51 |                 raise AnalyzeError('invalid JSON content')
52 |     else:
53 |         if not isinstance(data, list):
54 |             # should be like [{"title": "xxx", "artists_name": "yyy"}]
55 |             raise AnalyzeError('content has invalid format')
56 |         parse_each_fn = json_fn
57 |         items = data
58 | 
59 |     err_count = 0
60 |     songs = []
61 |     for each in items:
62 |         result = parse_each_fn(each)
63 |         if result is not None:
64 |             title, artists_name = result
65 |             song = create_dummy_brief_song(title, artists_name)
66 |             songs.append(song)
67 |         else:
68 |             err_count += 1
69 |     return songs, err_count
70 | 


--------------------------------------------------------------------------------
/feeluown/fuoexec/fuoexec.py:
--------------------------------------------------------------------------------
 1 | """
 2 | TODO: remove fuoexec prefix from these functions.
 3 | """
 4 | 
 5 | import os
 6 | from functools import wraps
 7 | from typing import Callable, Any, Dict
 8 | 
 9 | from feeluown.config import Config
10 | from feeluown.consts import DEFAULT_RCFILE_PATH
11 | 
12 | 
13 | _exec_globals: Dict[str, Any] = {}
14 | 
15 | 
16 | def fuoexec_get_globals() -> Dict:
17 |     return _exec_globals
18 | 
19 | 
20 | def fuoexec_S(func: Callable) -> str:
21 |     """function to symbol"""
22 |     return func.__name__
23 | 
24 | 
25 | def fuoexec_F(symbol: str) -> Callable:
26 |     """symbol to function"""
27 |     try:
28 |         return fuoexec_get_globals()[symbol]
29 |     except KeyError:
30 |         raise RuntimeError('no such symbol in globals') from None
31 | 
32 | 
33 | def fuoexec_load_file(filepath: str):
34 |     with open(filepath, encoding='UTF-8') as f:
35 |         code = compile(f.read(), filepath, 'exec')
36 |         fuoexec(code)
37 | 
38 | 
39 | def fuoexec_load_rcfile(config: Config):
40 |     fuoexec_get_globals()['config'] = config
41 |     if os.path.exists(DEFAULT_RCFILE_PATH):
42 |         fuoexec_load_file(DEFAULT_RCFILE_PATH)
43 | 
44 | 
45 | def fuoexec_init(app):
46 |     from feeluown.library import resolve, reverse
47 | 
48 |     signal_mgr.initialize(app)
49 |     fuoexec_get_globals()['app'] = app
50 |     fuoexec_get_globals()['resolve'] = resolve
51 |     fuoexec_get_globals()['reverse'] = reverse
52 | 
53 | 
54 | def fuoexec(obj):
55 |     # pylint: disable=exec-used
56 |     exec(obj, fuoexec_get_globals())
57 | 
58 | 
59 | def expose_to_rcfile(aliases=None) -> Callable:  # pylint: disable=unused-argument
60 |     """Decorator for exposing function to rcfile namespace with aliases
61 | 
62 |     :param aliases: list or string
63 |     """
64 |     def deco(func):
65 |         nonlocal aliases
66 | 
67 |         g = fuoexec_get_globals()
68 |         g[fuoexec_S(func)] = func
69 |         aliases = aliases if aliases else []
70 |         aliases = aliases if isinstance(aliases, list) else [aliases]
71 |         for alias in aliases:
72 |             g[alias] = func
73 | 
74 |         @wraps(func)
75 |         def wrapper(*args, **kwargs):
76 |             return func(*args, **kwargs)
77 |         return wrapper
78 |     return deco
79 | 
80 | 
81 | # pylint: disable=wrong-import-position, cyclic-import
82 | from .signal_manager import signal_mgr  # noqa
83 | 


--------------------------------------------------------------------------------
/tests/test_task.py:
--------------------------------------------------------------------------------
 1 | import asyncio
 2 | from unittest import mock
 3 | from threading import Thread
 4 | 
 5 | import pytest
 6 | 
 7 | from feeluown.task import TaskManager, PreemptiveTaskSpec
 8 | 
 9 | 
10 | @pytest.mark.asyncio
11 | async def test_task_manager(app_mock):
12 |     task_mgr = TaskManager(app_mock)
13 |     task_spec = task_mgr.get_or_create('fetch-song-standby')
14 | 
15 |     async def fetch_song():
16 |         pass
17 | 
18 |     mock_done_cb = mock.MagicMock()
19 |     task = task_spec.bind_coro(fetch_song())
20 |     task.add_done_callback(mock_done_cb)
21 |     await asyncio.sleep(0.1)  # let task run
22 |     assert mock_done_cb.called is True
23 | 
24 | 
25 | @pytest.mark.asyncio
26 | async def test_preemptive_task_spec_bind_coro():
27 |     mgr = mock.MagicMock()
28 |     loop = asyncio.get_running_loop()
29 |     mgr.loop = loop
30 |     task_spec = PreemptiveTaskSpec(mgr, 'fetch-song-standby')
31 | 
32 |     mock_cancelled_cb = mock.MagicMock()
33 | 
34 |     async def fetch_song():
35 |         try:
36 |             await asyncio.sleep(0.1)
37 |         except asyncio.CancelledError:
38 |             mock_cancelled_cb()
39 | 
40 |     task_spec.bind_coro(fetch_song())
41 |     await asyncio.sleep(0.1)  # let fetch_song run
42 |     await task_spec.bind_coro(fetch_song())
43 |     assert mock_cancelled_cb.called is True
44 | 
45 | 
46 | @pytest.mark.asyncio
47 | async def test_preemptive_task_spec_bind_coro_in_otherthread():
48 |     """
49 |     when there exists some RuntimeWarning(coroutine is never waited),
50 |     we should should regard this testcase as failed.
51 |     """
52 |     mgr = mock.MagicMock()
53 |     loop = asyncio.get_event_loop()
54 |     mgr.loop = loop
55 |     task_spec = PreemptiveTaskSpec(mgr, 'fetch-song-standby')
56 |     task_spec.bind_coro(asyncio.sleep(0.1))
57 |     t = Thread(target=task_spec.bind_coro, args=(asyncio.sleep(0.1), ))
58 |     t.start()
59 |     t.join()
60 |     await asyncio.sleep(0.1)
61 | 
62 | 
63 | @pytest.mark.asyncio
64 | async def test_preemptive_task_spec_bind_blocking_io_in_otherthread():
65 |     mgr = mock.MagicMock()
66 |     loop = asyncio.get_event_loop()
67 |     mgr.loop = loop
68 |     task_spec = PreemptiveTaskSpec(mgr, 'fetch-song-standby')
69 |     task_spec.bind_blocking_io(lambda: 0)
70 |     t = Thread(target=task_spec.bind_blocking_io, args=(lambda: 0, ))
71 |     t.start()
72 |     t.join()
73 |     await asyncio.sleep(0.1)
74 | 


--------------------------------------------------------------------------------
/feeluown/__init__.py:
--------------------------------------------------------------------------------
 1 | # -*- coding: utf-8 -*-
 2 | 
 3 | import logging
 4 | import logging.config
 5 | 
 6 | from .consts import LOG_FILE
 7 | 
 8 | 
 9 | __version__ = '5.0a0'
10 | 
11 | 
12 | dict_config = {
13 |     'version': 1,
14 |     'disable_existing_loggers': False,
15 |     'formatters': {
16 |         'standard': {
17 |             'format': ("[%(asctime)s %(name)s:%(lineno)d] "
18 |                        "[%(levelname)s]: %(message)s"),
19 |         },
20 |         'thread': {
21 |             'format': ("[%(asctime)s %(name)s:%(lineno)d %(thread)d] "
22 |                        "[%(levelname)s]: %(message)s"),
23 |         },
24 |     },
25 |     'handlers': {},
26 |     'loggers': {},
27 | }
28 | 
29 | 
30 | def logger_config(verbose=1, to_file=False):
31 |     """configure logger
32 | 
33 |     :param to_file: redirect log to file
34 |     :param verbose: verbose level.
35 |                     0: show all (>=)warning level log
36 |                     1: show all info level log
37 |                     2: show feeluown debug level log and all info log
38 |                     3: show all debug log
39 |     """
40 |     handler = {'level': 'DEBUG', 'formatter': 'standard'}
41 |     logger = {
42 |         'handlers': [''],
43 |         'propagate': True,
44 |     }
45 | 
46 |     dict_config['handlers'][''] = handler
47 |     dict_config['loggers'][''] = logger
48 | 
49 |     if to_file:
50 |         handler.update({
51 |             'class': 'logging.FileHandler',
52 |             'filename': LOG_FILE,
53 |             'mode': 'w'
54 |         })
55 |         verbose = max(1, verbose)
56 |     else:
57 |         handler.update({'class': 'logging.StreamHandler'})
58 | 
59 |     if verbose <= 0:
60 |         handler['level'] = 'WARNING'
61 |         logger['level'] = logging.WARNING
62 |     elif verbose <= 1:
63 |         handler['level'] = 'INFO'
64 |         logger['level'] = logging.INFO
65 |     else:
66 |         handler['level'] = 'DEBUG'
67 |         logger['level'] = logging.INFO
68 |         if verbose >= 3:
69 |             logger['level'] = logging.DEBUG
70 |         else:
71 |             # set logger for feeluown
72 |             fuo_logger = {
73 |                 'handlers': [''],
74 |                 'level': logging.DEBUG,
75 |                 'propagate': False,
76 |             }
77 |             dict_config['loggers']['feeluown'] = fuo_logger
78 | 
79 |     logging.config.dictConfig(dict_config)
80 | 


--------------------------------------------------------------------------------
/feeluown/gui/widgets/lyric.py:
--------------------------------------------------------------------------------
 1 | from PyQt6.QtCore import Qt
 2 | from PyQt6.QtWidgets import QListWidget, QListWidgetItem, QAbstractItemView, QFrame
 3 | 
 4 | from feeluown.player import Lyric
 5 | 
 6 | 
 7 | class LyricView(QListWidget):
 8 |     """Scollable lyrics list view.
 9 | 
10 |     Two slots can be connected:
11 |     1. on_line_changed
12 |     2. on_lyric_changed
13 |     """
14 | 
15 |     def __init__(self, parent):
16 |         super().__init__(parent)
17 | 
18 |         self._lyric = None
19 |         self._alignment = Qt.AlignmentFlag.AlignLeft
20 |         self._highlight_font_size = 18
21 | 
22 |         self.setFrameShape(QFrame.Shape.NoFrame)
23 |         self.setVerticalScrollBarPolicy(Qt.ScrollBarPolicy.ScrollBarAlwaysOff)
24 |         self.setHorizontalScrollBarPolicy(Qt.ScrollBarPolicy.ScrollBarAlwaysOff)
25 |         self.setWordWrap(True)
26 | 
27 |         self.currentItemChanged.connect(self.on_item_changed)
28 | 
29 |     def set_lyric(self, lyric: Lyric):
30 |         self.clear()
31 |         self._lyric = lyric
32 |         if lyric is None:
33 |             return
34 | 
35 |         for i, line in enumerate(lyric.lines):
36 |             item = self._create_item(line)
37 |             if i == lyric.current_index:
38 |                 self.setCurrentItem(item)
39 |             self.addItem(item)
40 | 
41 |     def _create_item(self, line):
42 |         item = QListWidgetItem(line)
43 |         item.setTextAlignment(self._alignment)
44 |         return item
45 | 
46 |     def clear(self):
47 |         super().clear()
48 |         self._lyric = None
49 | 
50 |     def on_line_changed(self, _):
51 |         if self._lyric is None:
52 |             return
53 |         index = self._lyric.current_index
54 |         if index is not None:
55 |             item = self.item(index)
56 |             self.setCurrentItem(item)
57 |             self.scrollToItem(item, QAbstractItemView.ScrollHint.PositionAtCenter)
58 |         else:
59 |             self.setCurrentItem(None)
60 | 
61 |     def on_lyric_changed(self, lyric, _, *__):
62 |         self.set_lyric(lyric)
63 | 
64 |     def on_item_changed(self, current, previous):
65 |         self.reset_item(previous)
66 |         if current:
67 |             font = current.font()
68 |             font.setPixelSize(self._highlight_font_size)
69 |             current.setFont(font)
70 | 
71 |     def reset_item(self, item):
72 |         if item:
73 |             item.setFont(self.font())
74 | 


--------------------------------------------------------------------------------
/feeluown/player/metadata.py:
--------------------------------------------------------------------------------
 1 | from collections.abc import MutableMapping
 2 | from enum import Enum
 3 | 
 4 | 
 5 | class MetadataFields(Enum):
 6 |     """
 7 |     Check the following docs for fields definition
 8 |     0. id3tag
 9 |     1. www.freedesktop.org -> mpris-spec/metadata/#fields
10 |     2. developer.apple.com -> MPMediaItem
11 |     """
12 |     # setby refers to how the metadata is generated/gotten.
13 |     setby = '__setby__'
14 | 
15 |     uri = 'uri'  # uri with `fuo` scheme
16 | 
17 |     title = 'title'
18 |     artists = 'artists'  # The value is a list of strings.
19 |     album = 'album'
20 |     year = 'year'
21 |     genre = 'genre'
22 |     track = 'track'  # The track number on the album disc.
23 |     source = 'source'
24 |     artwork = 'artwork'  # The album/video cover iamge url.
25 |     released = 'released'  # The released date of the song or it's album.
26 | 
27 | 
28 | class Metadata(MutableMapping):
29 |     """Metadata is a dict that transform the key to MetadataFields.
30 | 
31 |     >>> m = Metadata({'title': 'hello world'})
32 |     >>> m['title']
33 |     'hello world'
34 |     >>> m.get('notexist', 'notexist')
35 |     'notexist'
36 |     """
37 |     def __init__(self, *args, **kwargs):
38 |         self._store = dict()
39 |         self.update(dict(*args, **kwargs))
40 | 
41 |     def __getitem__(self, name):
42 |         return self._store[self._to_field(name)]
43 | 
44 |     def __contains__(self, name):
45 |         return self._to_field(name) in self._store
46 | 
47 |     def __setitem__(self, key, value):
48 |         field = self._to_field(key)
49 | 
50 |         # Validate the type of a value
51 |         field_type_mapping = {
52 |             MetadataFields.artists: list,
53 |         }
54 |         expected = field_type_mapping.get(field, str)
55 |         if not isinstance(value, expected):
56 |             raise ValueError(f'field {field} expect {expected}, acture {type(value)}')
57 |         self._store[field] = value
58 | 
59 |     def __delitem__(self, key):
60 |         del self._store[self._to_field(key)]
61 | 
62 |     def __iter__(self):
63 |         return iter(self._store)
64 | 
65 |     def __len__(self):
66 |         return len(self._store)
67 | 
68 |     def _to_field(self, name):
69 |         try:
70 |             field = MetadataFields(name)
71 |         except ValueError:
72 |             raise KeyError(f'invalid key {name}') from None
73 |         else:
74 |             return field
75 | 


--------------------------------------------------------------------------------
/feeluown/gui/tips.py:
--------------------------------------------------------------------------------
 1 | import random
 2 | 
 3 | from feeluown.utils import aio
 4 | from feeluown.app import App
 5 | 
 6 | # (nickname, name), no order.
 7 | # Write your Chinese(or other languages) name if you want.
 8 | Contributors = [
 9 |     ("cosven", "Shaowen Yin"),
10 |     ("felixonmars", ""),
11 |     ("PhotonQuantum", ""),
12 |     ("wuliaotc", ""),
13 |     ("cyliuu", ""),
14 |     ("light4", ""),
15 |     ("hjlarry", ""),
16 |     ("sbwtw", ""),
17 |     ("oryxfea", ""),
18 |     ("poly000", ""),
19 |     ("BruceZhang1993", ""),
20 |     ("cposture", ""),
21 |     ("helinb", ""),
22 |     ("Yexiaoxing", ""),
23 |     ("albertofwb", ""),
24 |     ("catsout", ""),
25 |     ("Torxed", ""),
26 |     ("RealXuChe", ""),
27 |     ("rapiz1", ""),
28 |     ("Heersin", ""),
29 |     ("chen-chao", ""),
30 |     ("keter42", ""),
31 |     ("timgates42", ""),
32 |     ("junhan-z", ""),
33 |     ("CareF", ""),
34 |     ("berberman", ""),
35 |     ("SaneBow", ""),
36 |     ("wsyxbcl", ""),
37 |     ("Thearas", ""),
38 |     ("reaink", ""),
39 |     ("wenLiangcan", ""),
40 |     ("leedagee", ""),
41 |     ("hanchanli", ""),
42 |     ("xssss1", ""),
43 |     ("williamherry", ""),
44 |     ("seiuneko", ""),
45 |     ("emmanuel-ferdman", ""),
46 | ]
47 | 
48 | 
49 | class TipsManager:
50 |     """在合适的时候展示一些使用 Tip"""
51 | 
52 |     tips = [
53 |         "你知道 FeelUOwn 可以配合 osdlyrics 使用吗?",
54 |         "搜索快捷键是 Ctrl + F",
55 |         "在搜索框输入“>>> app.tips_mgr.show_random()”查看更多 Tips",
56 |         "专辑图片上右键可以查看原图哦 ~",
57 |         "可以拖动歌曲来将歌曲添加到歌单呐!",
58 |         "鼠标悬浮或右键常有惊喜 ~",
59 |         "开启 watch 模式一边看 MV,一边工作学习香不香?",
60 |     ]
61 | 
62 |     def __init__(self, app: App):
63 |         self._app = app
64 |         self._app.started.connect(self.show_random_after_5s)
65 | 
66 |     def show_random(self):
67 |         if random.randint(0, 10) < 5:
68 |             self._app.show_msg(random.choice(self.tips), timeout=2500)
69 |         else:
70 |             contributor = random.choice(Contributors)
71 |             nickname, name = contributor
72 |             if name:
73 |                 user = f"{name} (@{nickname})"
74 |             else:
75 |                 user = f"@{nickname}"
76 |             msg = f"感谢 {user} 的贡献 :)"
77 |             self._app.show_msg(msg, timeout=2500)
78 | 
79 |     def show_random_after_5s(self, *_):
80 |         async def task():
81 |             await aio.sleep(5)
82 |             self.show_random()
83 | 
84 |         aio.run_afn(task)
85 | 


--------------------------------------------------------------------------------
/docs/source/features.rst:
--------------------------------------------------------------------------------
 1 | 特性
 2 | =========
 3 | 
 4 | 安装相对简单,新手友好
 5 | ----------------------------
 6 | 
 7 |   参考 :doc:`quickstart` 文档进行安装。
 8 | 
 9 | 提供各音乐平台插件
10 | ---------------------------
11 | 
12 |   - `Youtube Music `_
13 |   - `网易云音乐 `_
14 |   - `QQ 音乐 `_
15 |   - `Bilibili `_
16 | 
17 | 自动寻找播放资源
18 | ----------------------------
19 | 
20 |   在搜索框输入 ``==> 我怀念的 - 孙燕姿`` ,播放器会自动匹配歌曲并进行播放。
21 |   当你播放 A 平台的 VIP/收费歌曲时,播放器会尝试从其它平台为你寻找免费资源(你需要安装各音乐平台插件)。
22 | 
23 | 自然语言转歌单(AI)
24 | ----------------------------
25 | 
26 |     .. image:: https://github.com/user-attachments/assets/8afa13e6-8ff9-4b4f-9ca7-ad1f5661d8cb
27 | 
28 | 基于文本的歌单
29 | ----------------------------
30 | 
31 |   将下面内容拷贝到文件 ``~/.FeelUOwn/collections/library.fuo`` 中,重启 FeelUOwn 就可以看到此歌单::
32 | 
33 |      fuo://netease/songs/16841667  # No Matter What - Boyzone
34 |      fuo://netease/songs/65800     # 最佳损友 - 陈奕迅
35 |      fuo://xiami/songs/3406085     # Love Story - Taylor Swift
36 |      fuo://netease/songs/5054926   # When You Say Noth… - Ronan Keating
37 |      fuo://qqmusic/songs/97773     # 晴天 - 周杰伦
38 |      fuo://qqmusic/songs/102422162 # 给我一首歌的时间 … - 周杰伦,蔡依林
39 |      fuo://xiami/songs/1769834090  # Flower Dance - DJ OKAWARI
40 | 
41 |   你可以通过 gist 来分享自己的歌单,也可以通过 Dropbox 或 git 来在不同设备上同步这些歌单。
42 | 
43 | 支持读取 fuorc 文件
44 | ----------------------------
45 | 
46 |   你配置过 ``.emacs`` 或者 ``.vimrc`` 吗? ``.fuorc`` 和它们一样强大!
47 |   参考 :doc:`fuorc` 文档来编写自己的 rc 文件吧~
48 | 
49 | 提供基于 TCP 的控制协议
50 | ----------------------------
51 | 
52 |   比如::
53 | 
54 |      查看播放器状态 echo status | nc localhost 23333
55 |      暂停播放      echo status | nc localhost 23333
56 |      搜索歌曲      echo "search 周杰伦" | nc localhost 23333
57 | 
58 |   因此,它 **可以方便的与 Tmux, Emacs, Slack 等常用程序和软件集成**
59 | 
60 |     - `Emacs 简单客户端 `_ ,
61 |       `DEMO 演示视频 `_
62 |     - Tmux 集成截图
63 | 
64 |       .. image:: https://user-images.githubusercontent.com/4962134/43565894-1586891e-965f-11e8-9cde-50973acfb573.png
65 | 
66 |     - Slack 集成截图 `(在 fuorc 添加配置) `_
67 | 
68 |       .. image:: https://user-images.githubusercontent.com/4962134/43578665-0d148af6-9682-11e8-9d95-4cd1d3c1e0b9.png
69 | 
70 | 支持无 GUI 模式启动
71 | ---------------------------
72 | 


--------------------------------------------------------------------------------
/feeluown/app/server_app.py:
--------------------------------------------------------------------------------
 1 | import asyncio
 2 | import logging
 3 | 
 4 | from feeluown.server.pubsub import (
 5 |     Gateway as PubsubGateway,
 6 |     LiveLyricPublisher,
 7 | )
 8 | from feeluown.server.pubsub.publishers import SignalPublisher
 9 | from feeluown.server import FuoServer, ProtocolType
10 | from feeluown.nowplaying import run_nowplaying_server
11 | from .app import App
12 | 
13 | logger = logging.getLogger(__name__)
14 | 
15 | 
16 | class ServerApp(App):
17 | 
18 |     def __init__(self, *args, **kwargs):
19 |         super().__init__(*args, **kwargs)
20 | 
21 |         self.rpc_server = FuoServer(self, ProtocolType.rpc)
22 |         self.pubsub_server = FuoServer(self, ProtocolType.pubsub)
23 |         self.pubsub_gateway = PubsubGateway()
24 |         self._ll_publisher = LiveLyricPublisher(self.pubsub_gateway)
25 |         self._signal_publish = SignalPublisher(self.pubsub_gateway)
26 | 
27 |     def initialize(self):
28 |         super().initialize()
29 |         self.live_lyric.sentence_changed.connect(self._ll_publisher.publish)
30 | 
31 |         signals = [
32 |             (self.player.metadata_changed, 'player.metadata_changed'),
33 |             (self.player.seeked, 'player.seeked'),
34 |             (self.player.state_changed, 'player.state_changed'),
35 |             (self.player.duration_changed, 'player.duration_changed'),
36 |             (self.live_lyric.sentence_changed, 'live_lyric.sentence_changed'),
37 |         ]
38 |         for signal, name in signals:
39 |             self.pubsub_gateway.add_topic(name)
40 |             signal.connect(self._signal_publish.on_emitted(name),
41 |                            weak=False,
42 |                            aioqueue=True)
43 | 
44 |     def run(self):
45 |         super().run()
46 | 
47 |         asyncio.create_task(
48 |             self.rpc_server.run(self.get_listen_addr(), self.config.RPC_PORT))
49 |         asyncio.create_task(
50 |             self.pubsub_server.run(
51 |                 self.get_listen_addr(),
52 |                 self.config.PUBSUB_PORT,
53 |             ))
54 |         if self.config.ENABLE_WEB_SERVER:
55 |             try:
56 |                 from feeluown.webserver import run_web_server
57 |             except ImportError as e:
58 |                 logger.error(f"can't enable webserver, err: {e}")
59 |             else:
60 |                 asyncio.create_task(
61 |                     run_web_server(self.get_listen_addr(), self.config.WEB_PORT))
62 |         asyncio.create_task(run_nowplaying_server(self))
63 | 


--------------------------------------------------------------------------------
/feeluown/local/__init__.py:
--------------------------------------------------------------------------------
 1 | # -*- coding: utf-8 -*-
 2 | import os
 3 | import logging
 4 | 
 5 | from feeluown.utils import aio  # noqa
 6 | from .provider import provider  # noqa
 7 | 
 8 | DEFAULT_MUSIC_FOLDER = os.path.expanduser('~') + '/Music'
 9 | DEFAULT_MUSIC_EXTS = ['mp3', 'ogg', 'wma', 'm4a', 'm4v', 'mp4', 'flac', 'ape', 'wav']
10 | 
11 | __alias__ = '本地音乐'
12 | __feeluown_version__ = '1.1.0'
13 | __version__ = '0.1a0'
14 | __desc__ = '本地音乐'
15 | 
16 | logger = logging.getLogger(__name__)
17 | 
18 | 
19 | def init_config(config):
20 |     config.deffield('MUSIC_FOLDERS',
21 |                     type_=list,
22 |                     default=[DEFAULT_MUSIC_FOLDER],
23 |                     desc='支持的音乐文件夹列表')
24 |     config.deffield('MUSIC_FORMATS',
25 |                     type_=list,
26 |                     default=DEFAULT_MUSIC_EXTS,
27 |                     desc='支持的音乐格式列表')
28 |     config.deffield('CORE_LANGUAGE', type_=str, default='auto', desc='默认显示的语言')
29 |     config.deffield('IDENTIFIER_DELIMITER',
30 |                     type_=str,
31 |                     default='',
32 |                     desc='生成identifier时的连接符')
33 |     config.deffield('EXPAND_ARTIST_SONGS',
34 |                     type_=bool,
35 |                     default=False,
36 |                     desc='将专辑艺术家的专辑中歌曲加入到该艺术家的歌曲中')
37 |     config.deffield('ARTIST_SPLITTER', type_=list, default=[',', '&'], desc='歌曲艺术家的分隔符')
38 |     config.deffield('ARTIST_SPLITTER_IGNORANCE',
39 |                     type_=list,
40 |                     default=None,
41 |                     desc='对艺术家信息使用分隔符时需要进行保护的字符串')
42 |     config.deffield('SPLIT_ALBUM_ARTIST_NAME',
43 |                     type_=bool,
44 |                     default=False,
45 |                     desc='支持使用分隔符分隔专辑艺术家')
46 | 
47 | 
48 | async def autoload(app):
49 |     await aio.run_fn(provider.scan, app.config.local, app.config.local.MUSIC_FOLDERS)
50 | 
51 |     app.show_msg('本地音乐扫描完毕')
52 | 
53 | 
54 | def enable(app):
55 |     logger.info('Register provider: %s', provider)
56 |     app.library.register(provider)
57 |     provider.initialize(app)
58 | 
59 |     app.started.connect(lambda *args: aio.create_task(autoload(*args)),
60 |                         weak=False,
61 |                         aioqueue=False)
62 |     if app.mode & app.GuiMode:
63 |         from .provider_ui import LocalProviderUi
64 | 
65 |         provider_ui = LocalProviderUi(app)
66 |         app.pvd_ui_mgr.register(provider_ui)
67 | 
68 | 
69 | def disable(app):
70 |     logger.info('唔,不要禁用我')
71 | 


--------------------------------------------------------------------------------
/feeluown/utils/cache.py:
--------------------------------------------------------------------------------
 1 | import time
 2 | from threading import RLock
 3 | 
 4 | 
 5 | _NOT_FOUND = object()
 6 | 
 7 | 
 8 | class cached_field:
 9 |     """like functools.cached_property, but designed for Model
10 | 
11 |     >>> class User:
12 |     ...     @cached_field()
13 |     ...     def playlists(self):
14 |     ...         return [1, 2]
15 |     ...
16 |     >>> user = User()
17 |     >>> user2 = User()
18 |     >>> user.playlists = None
19 |     >>> user.playlists
20 |     [1, 2]
21 |     >>> user.playlists = [3, 4]
22 |     >>> user.playlists
23 |     [3, 4]
24 |     >>> user2.playlists
25 |     [1, 2]
26 |     """
27 |     def __init__(self, ttl=None):
28 |         self._ttl = ttl
29 |         self.lock = RLock()
30 | 
31 |     def __call__(self, func):
32 |         self.func = func
33 |         return self
34 | 
35 |     def __get__(self, obj, owner):
36 |         if obj is None:  # Class.field
37 |             return self
38 | 
39 |         try:
40 |             # XXX: maybe we can use use a special attribute
41 |             # (such as _cached_{name}) to store the cache value
42 |             # instead of __dict__
43 |             cache = obj.__dict__
44 |         except AttributeError:
45 |             raise TypeError("obj should have __dict__ attribute") from None
46 | 
47 |         cache_key = '_cache_' + self.func.__name__
48 |         datum = cache.get(cache_key, _NOT_FOUND)
49 |         if self._should_refresh_datum(datum):
50 |             with self.lock:
51 |                 # check if another thread filled cache while we awaited lock
52 |                 datum = cache.get(cache_key, _NOT_FOUND)
53 |                 if self._should_refresh_datum(datum):
54 |                     value = self.func(obj)
55 |                     cache[cache_key] = datum = self._gen_datum(value)
56 |         return datum[1]
57 | 
58 |     def __set__(self, obj, value):
59 |         cache_key = '_cache_' + self.func.__name__
60 |         obj.__dict__[cache_key] = self._gen_datum(value)
61 | 
62 |     def _should_refresh_datum(self, datum):
63 |         return (
64 |             datum is _NOT_FOUND or  # not initialized
65 |             datum[1] is None or     # None implies that the value can be refreshed
66 |             datum[0] is not None and datum[0] < time.time()  # expired
67 |         )
68 | 
69 |     def _gen_datum(self, value):
70 |         if self._ttl is None:
71 |             expired_at = None
72 |         else:
73 |             expired_at = int(time.time()) + self._ttl
74 |         return (expired_at, value)
75 | 


--------------------------------------------------------------------------------
/feeluown/gui/pages/provider_home.py:
--------------------------------------------------------------------------------
 1 | import logging
 2 | from typing import TYPE_CHECKING
 3 | 
 4 | from PyQt6.QtWidgets import QWidget, QHBoxLayout, QVBoxLayout, QScrollArea, QFrame
 5 | 
 6 | from feeluown.gui.widgets.my_music import MyMusicView
 7 | from feeluown.gui.widgets.header import LargeHeader, MidHeader
 8 | from feeluown.gui.widgets.playlists import PlaylistsView
 9 | 
10 | 
11 | if TYPE_CHECKING:
12 |     from feeluown.app.gui_app import GuiApp
13 | 
14 | 
15 | logger = logging.getLogger(__name__)
16 | 
17 | 
18 | async def render(req, identifier, **kwargs):
19 |     app: "GuiApp" = req.ctx["app"]
20 |     provider = app.library.get(identifier)
21 |     app.ui.right_panel.set_body(View(app, provider))
22 | 
23 | 
24 | class View(QWidget):
25 |     def __init__(self, app: "GuiApp", provider, *args, **kwargs) -> None:
26 |         super().__init__(*args, **kwargs)
27 |         self._app = app
28 |         self._provider = provider
29 | 
30 |         self.title = LargeHeader(self._provider.name, parent=self)
31 |         self.my_music_header = MidHeader("我的音乐", parent=self)
32 |         self.my_music_view = MyMusicView(parent=self)
33 |         self.playlists_header = MidHeader("歌单列表", parent=self)
34 |         self.playlists_view = PlaylistsView(parent=self)
35 | 
36 |         self.my_music_view.setModel(self._app.mymusic_uimgr.model)
37 |         self.playlists_view.setModel(self._app.pl_uimgr.model)
38 | 
39 |         self.playlists_view.show_playlist.connect(
40 |             lambda pl: self._app.browser.goto(model=pl)
41 |         )
42 | 
43 |         self._playlists_scroll = QScrollArea(self)
44 |         self._playlists_scroll.setWidget(self.playlists_view)
45 |         self._playlists_scroll.setWidgetResizable(True)
46 |         self.playlists_view.setFrameShape(QFrame.Shape.NoFrame)
47 | 
48 |         self._layout = QVBoxLayout(self)
49 |         self._body_layout = QHBoxLayout()
50 |         self._l_layout = QVBoxLayout()
51 |         self._r_layout = QVBoxLayout()
52 | 
53 |         self._layout.addWidget(self.title)
54 |         self._layout.addLayout(self._body_layout)
55 | 
56 |         self._body_layout.addLayout(self._l_layout)
57 |         self._body_layout.addLayout(self._r_layout)
58 | 
59 |         self._l_layout.addWidget(self.my_music_header)
60 |         self._l_layout.addWidget(self.my_music_view)
61 |         self._l_layout.addStretch(0)
62 | 
63 |         self._r_layout.addWidget(self.playlists_header)
64 |         self._r_layout.addWidget(self._playlists_scroll)
65 |         self._r_layout.addStretch(0)
66 | 


--------------------------------------------------------------------------------
/.github/workflows/macos-release.yml:
--------------------------------------------------------------------------------
 1 | name: Release for macos
 2 | on:
 3 |   push:
 4 |     tags:
 5 |       - v*
 6 |     branches:
 7 |       - master
 8 |   workflow_dispatch:
 9 | 
10 | jobs:
11 |   build-n-publish:
12 | 
13 |     runs-on: ${{ matrix.os }}
14 |     strategy:
15 |       # Allow some jobs fail.
16 |       fail-fast: false
17 |       matrix:
18 |         os: [macos-latest, macos-14]
19 | 
20 |     steps:
21 |     - uses: actions/checkout@master
22 |     - name: Set up Python
23 |       uses: actions/setup-python@v5
24 |       with:
25 |         python-version: "3.11"
26 |     - name: Build
27 |       run: |
28 |         python -m pip install --upgrade pip
29 |         pip install pyinstaller
30 |         pip install -e .[qt,macos,battery,cookies,ytdl,ai]
31 |     - name: Install libmpv
32 |       run: |
33 |         brew install mpv
34 |     - name: Setup OS version and ARCH
35 |       run: |
36 |         echo "FUO_MACOS_VERSION=`sw_vers --productVersion`" >> $GITHUB_ENV
37 |     - name: Setup DYLD path for macos-arm64
38 |       run: |
39 |         # Python can't find libmpv on macOS arm64 by default.
40 |         # This problem does not exist on macOS with x86_64.
41 |         echo "DYLD_FALLBACK_LIBRARY_PATH=/opt/homebrew/lib" >> $GITHUB_ENV
42 |         echo "DYLD_FALLBACK_FRAMEWORK_PATH=/opt/homebrew/Frameworks" >> $GITHUB_ENV
43 |     - name: Test if no syntax error
44 |       run: feeluown -h
45 |     - name: Bundle
46 |       run: |
47 |         make bundle
48 |     - name: Archive
49 |       run: |
50 |         # List dist to help double check if bundle is ok.
51 |         ls dist/
52 |         arch=`uname -m`
53 |         macos_version=`sw_vers -productVersion`
54 |         cd dist/ && zip FeelUOwnX-macOS${macos_version}-${arch}.zip -r FeelUOwnX.app/
55 |     - name: Upload artifact
56 |       uses: actions/upload-artifact@v4
57 |       with:
58 |         name: FeelUOwnX-macOS${{ env.FUO_MACOS_VERSION }}-${{ env.RUNNER_ARCH }}.zip
59 |         path: dist/FeelUOwnX*.zip
60 |     - name: Upload to release page
61 |       if: startsWith(github.ref, 'refs/tags/v')
62 |       uses: softprops/action-gh-release@v1
63 |       env:
64 |         GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
65 |       with:
66 |         files: dist/FeelUOwnX*.zip
67 |     - name: Upload to nightly
68 |       if: github.ref == 'refs/heads/master'
69 |       uses: softprops/action-gh-release@v2
70 |       env:
71 |         GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
72 |       with:
73 |         files: dist/FeelUOwnX*.zip
74 |         tag_name: nightly
75 | 


--------------------------------------------------------------------------------