parole-tui 1.0.0__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- parole_tui-1.0.0/PKG-INFO +89 -0
- parole_tui-1.0.0/README.md +76 -0
- parole_tui-1.0.0/pyproject.toml +43 -0
- parole_tui-1.0.0/pyproject.toml.orig +37 -0
- parole_tui-1.0.0/src/parole_tui/__init__.py +4 -0
- parole_tui-1.0.0/src/parole_tui/lrclib.py +34 -0
- parole_tui-1.0.0/src/parole_tui/monitor.py +221 -0
- parole_tui-1.0.0/src/parole_tui/parole.py +240 -0
- parole_tui-1.0.0/src/parole_tui/textual.tcss +17 -0
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
Metadata-Version: 2.3
|
|
2
|
+
Name: parole-tui
|
|
3
|
+
Version: 1.0.0
|
|
4
|
+
Summary: Parole is a TUI tool to display lyrics from the song you are currently playing !
|
|
5
|
+
Author: skueee
|
|
6
|
+
Author-email: skueee <skueee@outlook.com>
|
|
7
|
+
Requires-Dist: dbus-next>=0.2.3 ; sys_platform == 'linux'
|
|
8
|
+
Requires-Dist: httpx>=0.28.1
|
|
9
|
+
Requires-Dist: textual>=8.2.8
|
|
10
|
+
Requires-Dist: winsdk>=1.0.0b10 ; sys_platform == 'win32'
|
|
11
|
+
Requires-Python: >=3.14
|
|
12
|
+
Description-Content-Type: text/markdown
|
|
13
|
+
|
|
14
|
+
# parole
|
|
15
|
+
Parole is a TUI tool to display lyrics from the song you are currently playing !
|
|
16
|
+
|
|
17
|
+

|
|
18
|
+

|
|
19
|
+

|
|
20
|
+
|
|
21
|
+
> [!NOTE]
|
|
22
|
+
> While this tool is meant to be compatible with Windows, it could be more unstable due to less testing.
|
|
23
|
+
|
|
24
|
+
[](https://asciinema.org/a/1267969)
|
|
25
|
+
|
|
26
|
+
## Quick start
|
|
27
|
+
**1. Dependencies**
|
|
28
|
+
|
|
29
|
+
You will need pipx or pip to run this program
|
|
30
|
+
|
|
31
|
+
**2. Download the program**
|
|
32
|
+
```
|
|
33
|
+
pipx install parole-lyrics
|
|
34
|
+
```
|
|
35
|
+
or if you don't have pipx
|
|
36
|
+
```
|
|
37
|
+
pip install parole-lyrics
|
|
38
|
+
```
|
|
39
|
+
**3. Launch the program**
|
|
40
|
+
```
|
|
41
|
+
parole
|
|
42
|
+
```
|
|
43
|
+
|
|
44
|
+
## Usage
|
|
45
|
+
Here is a list of the arguments that it can take :
|
|
46
|
+
```
|
|
47
|
+
-h, --help Show the available arguments
|
|
48
|
+
--delay DELAY Delay at which the program will update lyrics
|
|
49
|
+
--no-song-infos Don't show song infos at the start of the song
|
|
50
|
+
--no-ansi Don't use terminal colors
|
|
51
|
+
--line-count LINE_COUNT Number of lyrics shown before and after the current one
|
|
52
|
+
```
|
|
53
|
+
|
|
54
|
+
## Features
|
|
55
|
+
- Show lyrics in terminal
|
|
56
|
+
- Multiplatform - Uses MPRIS or winsdk depending on your OS
|
|
57
|
+
- Some customization options
|
|
58
|
+
- Sync lyrics with music
|
|
59
|
+
|
|
60
|
+
## Development
|
|
61
|
+
You can see infos on how to contribute and develop on the CONTRIBUTING.md file
|
|
62
|
+
|
|
63
|
+
## How does that works ?
|
|
64
|
+
The program takes the current song using two ways, depending on the OS. If it runs on Linux, it will create a LinuxMediaProvider which uses MPRIS (through dbus-next) to get infos, while on Windows it uses a WindowsMediaProvider that uses winsdk to get infos. If the song changed, it sends a request to LRCLib to get the lyrics and display them !
|
|
65
|
+
|
|
66
|
+
## Stardance
|
|
67
|
+
This tool was made for Stardance, a program ran by [Hack Club](https://hackclub.com/)
|
|
68
|
+
|
|
69
|
+
[My project page](https://stardance.hackclub.com/projects/63249) | [Hack Club](https://hackclub.com/) | [Stardance Program](https://stardance.hackclub.com/)
|
|
70
|
+
|
|
71
|
+
## AI Notice
|
|
72
|
+
|
|
73
|
+
AI was used to do research in this project, but the code was always rewritten and edited by an human.
|
|
74
|
+
|
|
75
|
+
## Credit
|
|
76
|
+
|
|
77
|
+
### Libraries
|
|
78
|
+
- **dbus-next, to interact with MPRIS on Linux :** [pypi.org/project/dbus-next](https://pypi.org/project/dbus-next/)
|
|
79
|
+
- **Winsdk, to interact with Windows :** [pypi.org/project/winsdk](https://pypi.org/project/winsdk/)
|
|
80
|
+
- **Textual, to display the TUI :** [textual.textualize.io](https://textual.textualize.io/)
|
|
81
|
+
|
|
82
|
+
### API
|
|
83
|
+
- **LRCLib, to get the lyrics :** [lrclib.net](https://lrclib.net/)
|
|
84
|
+
|
|
85
|
+
### Project management
|
|
86
|
+
- **UV, to manage the project :** [docs.astral.sh/uv](https://docs.astral.sh/uv/)
|
|
87
|
+
- **uv-script, to create custom scripts (lint, format...) :** [pypi.org/project/uv-script](https://pypi.org/project/uv-script/)
|
|
88
|
+
- **Ruff, to lint and format :** [docs.astral.sh/ruff](https://docs.astral.sh/ruff/)
|
|
89
|
+
- **Deptry, to check dependencies problems :** [deptry.com](https://deptry.com/)
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
# parole
|
|
2
|
+
Parole is a TUI tool to display lyrics from the song you are currently playing !
|
|
3
|
+
|
|
4
|
+

|
|
5
|
+

|
|
6
|
+

|
|
7
|
+
|
|
8
|
+
> [!NOTE]
|
|
9
|
+
> While this tool is meant to be compatible with Windows, it could be more unstable due to less testing.
|
|
10
|
+
|
|
11
|
+
[](https://asciinema.org/a/1267969)
|
|
12
|
+
|
|
13
|
+
## Quick start
|
|
14
|
+
**1. Dependencies**
|
|
15
|
+
|
|
16
|
+
You will need pipx or pip to run this program
|
|
17
|
+
|
|
18
|
+
**2. Download the program**
|
|
19
|
+
```
|
|
20
|
+
pipx install parole-lyrics
|
|
21
|
+
```
|
|
22
|
+
or if you don't have pipx
|
|
23
|
+
```
|
|
24
|
+
pip install parole-lyrics
|
|
25
|
+
```
|
|
26
|
+
**3. Launch the program**
|
|
27
|
+
```
|
|
28
|
+
parole
|
|
29
|
+
```
|
|
30
|
+
|
|
31
|
+
## Usage
|
|
32
|
+
Here is a list of the arguments that it can take :
|
|
33
|
+
```
|
|
34
|
+
-h, --help Show the available arguments
|
|
35
|
+
--delay DELAY Delay at which the program will update lyrics
|
|
36
|
+
--no-song-infos Don't show song infos at the start of the song
|
|
37
|
+
--no-ansi Don't use terminal colors
|
|
38
|
+
--line-count LINE_COUNT Number of lyrics shown before and after the current one
|
|
39
|
+
```
|
|
40
|
+
|
|
41
|
+
## Features
|
|
42
|
+
- Show lyrics in terminal
|
|
43
|
+
- Multiplatform - Uses MPRIS or winsdk depending on your OS
|
|
44
|
+
- Some customization options
|
|
45
|
+
- Sync lyrics with music
|
|
46
|
+
|
|
47
|
+
## Development
|
|
48
|
+
You can see infos on how to contribute and develop on the CONTRIBUTING.md file
|
|
49
|
+
|
|
50
|
+
## How does that works ?
|
|
51
|
+
The program takes the current song using two ways, depending on the OS. If it runs on Linux, it will create a LinuxMediaProvider which uses MPRIS (through dbus-next) to get infos, while on Windows it uses a WindowsMediaProvider that uses winsdk to get infos. If the song changed, it sends a request to LRCLib to get the lyrics and display them !
|
|
52
|
+
|
|
53
|
+
## Stardance
|
|
54
|
+
This tool was made for Stardance, a program ran by [Hack Club](https://hackclub.com/)
|
|
55
|
+
|
|
56
|
+
[My project page](https://stardance.hackclub.com/projects/63249) | [Hack Club](https://hackclub.com/) | [Stardance Program](https://stardance.hackclub.com/)
|
|
57
|
+
|
|
58
|
+
## AI Notice
|
|
59
|
+
|
|
60
|
+
AI was used to do research in this project, but the code was always rewritten and edited by an human.
|
|
61
|
+
|
|
62
|
+
## Credit
|
|
63
|
+
|
|
64
|
+
### Libraries
|
|
65
|
+
- **dbus-next, to interact with MPRIS on Linux :** [pypi.org/project/dbus-next](https://pypi.org/project/dbus-next/)
|
|
66
|
+
- **Winsdk, to interact with Windows :** [pypi.org/project/winsdk](https://pypi.org/project/winsdk/)
|
|
67
|
+
- **Textual, to display the TUI :** [textual.textualize.io](https://textual.textualize.io/)
|
|
68
|
+
|
|
69
|
+
### API
|
|
70
|
+
- **LRCLib, to get the lyrics :** [lrclib.net](https://lrclib.net/)
|
|
71
|
+
|
|
72
|
+
### Project management
|
|
73
|
+
- **UV, to manage the project :** [docs.astral.sh/uv](https://docs.astral.sh/uv/)
|
|
74
|
+
- **uv-script, to create custom scripts (lint, format...) :** [pypi.org/project/uv-script](https://pypi.org/project/uv-script/)
|
|
75
|
+
- **Ruff, to lint and format :** [docs.astral.sh/ruff](https://docs.astral.sh/ruff/)
|
|
76
|
+
- **Deptry, to check dependencies problems :** [deptry.com](https://deptry.com/)
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
[project]
|
|
2
|
+
name = "parole-tui"
|
|
3
|
+
version = "1.0.0"
|
|
4
|
+
description = "Parole is a TUI tool to display lyrics from the song you are currently playing !"
|
|
5
|
+
readme = "README.md"
|
|
6
|
+
requires-python = ">=3.14"
|
|
7
|
+
dependencies = [
|
|
8
|
+
"dbus-next>=0.2.3 ; sys_platform == 'linux'",
|
|
9
|
+
"httpx>=0.28.1",
|
|
10
|
+
"textual>=8.2.8",
|
|
11
|
+
"winsdk>=1.0.0b10 ; sys_platform == 'win32'",
|
|
12
|
+
]
|
|
13
|
+
|
|
14
|
+
[[project.authors]]
|
|
15
|
+
name = "skueee"
|
|
16
|
+
email = "skueee@outlook.com"
|
|
17
|
+
|
|
18
|
+
[project.scripts]
|
|
19
|
+
parole = "parole:main"
|
|
20
|
+
|
|
21
|
+
[tool.uvs.scripts]
|
|
22
|
+
format = "uv run ruff format"
|
|
23
|
+
lint = "uv run ruff check"
|
|
24
|
+
fix = "uv run ruff check --fix"
|
|
25
|
+
deps_check = "deptry ."
|
|
26
|
+
checks = [
|
|
27
|
+
"fix",
|
|
28
|
+
"format",
|
|
29
|
+
"lint",
|
|
30
|
+
"deps_check",
|
|
31
|
+
]
|
|
32
|
+
|
|
33
|
+
[build-system]
|
|
34
|
+
requires = ["uv_build>=0.12.15,<0.13.0"]
|
|
35
|
+
build-backend = "uv_build"
|
|
36
|
+
|
|
37
|
+
[dependency-groups]
|
|
38
|
+
dev = [
|
|
39
|
+
"deptry>=0.25.1",
|
|
40
|
+
"ruff>=0.16.8",
|
|
41
|
+
"textual-dev>=1.8.0",
|
|
42
|
+
"uv-script>=0.1.9",
|
|
43
|
+
]
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
[project]
|
|
2
|
+
name = "parole-tui"
|
|
3
|
+
version = "1.0.0"
|
|
4
|
+
description = "Parole is a TUI tool to display lyrics from the song you are currently playing !"
|
|
5
|
+
readme = "README.md"
|
|
6
|
+
authors = [
|
|
7
|
+
{ name = "skueee", email = "skueee@outlook.com" }
|
|
8
|
+
]
|
|
9
|
+
requires-python = ">=3.14"
|
|
10
|
+
dependencies = [
|
|
11
|
+
"dbus-next>=0.2.3 ; sys_platform == 'linux'",
|
|
12
|
+
"httpx>=0.28.1",
|
|
13
|
+
"textual>=8.2.8",
|
|
14
|
+
"winsdk>=1.0.0b10 ; sys_platform == 'win32'",
|
|
15
|
+
]
|
|
16
|
+
|
|
17
|
+
[project.scripts]
|
|
18
|
+
parole = "parole:main"
|
|
19
|
+
|
|
20
|
+
[tool.uvs.scripts]
|
|
21
|
+
format = "uv run ruff format"
|
|
22
|
+
lint = "uv run ruff check"
|
|
23
|
+
fix = "uv run ruff check --fix"
|
|
24
|
+
deps_check = "deptry ."
|
|
25
|
+
checks = ["fix", "format", "lint", "deps_check"]
|
|
26
|
+
|
|
27
|
+
[build-system]
|
|
28
|
+
requires = ["uv_build>=0.12.15,<0.13.0"]
|
|
29
|
+
build-backend = "uv_build"
|
|
30
|
+
|
|
31
|
+
[dependency-groups]
|
|
32
|
+
dev = [
|
|
33
|
+
"deptry>=0.25.1",
|
|
34
|
+
"ruff>=0.16.8",
|
|
35
|
+
"textual-dev>=1.8.0",
|
|
36
|
+
"uv-script>=0.1.9",
|
|
37
|
+
]
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
import asyncio
|
|
2
|
+
import time
|
|
3
|
+
|
|
4
|
+
import httpx
|
|
5
|
+
|
|
6
|
+
rate_limit_timeout: int = 0
|
|
7
|
+
endpoint = "https://lrclib.net"
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
async def get(url: str, params: httpx.ParamsType = None) -> httpx.Response:
|
|
11
|
+
global rate_limit_timeout
|
|
12
|
+
|
|
13
|
+
if int(time.time()) < rate_limit_timeout:
|
|
14
|
+
await asyncio.sleep(int(time.time()) - rate_limit_timeout)
|
|
15
|
+
|
|
16
|
+
async with httpx.AsyncClient() as client:
|
|
17
|
+
r = await client.get(url, params=params)
|
|
18
|
+
|
|
19
|
+
# Handle rate limit
|
|
20
|
+
if r.status_code == 429:
|
|
21
|
+
rate_limit_timeout = int(time.time()) + int(r.headers["Retry-After"])
|
|
22
|
+
r = await get(url, params)
|
|
23
|
+
|
|
24
|
+
return r
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
async def get_lyrics(artist: str, title: str, album: str | None = None):
|
|
28
|
+
if album != None:
|
|
29
|
+
parameters = {"track_name": title, "artist_name": artist, "album_name": album}
|
|
30
|
+
else:
|
|
31
|
+
parameters = {"track_name": title, "artist_name": artist}
|
|
32
|
+
|
|
33
|
+
response = await get(endpoint + "/api/get", params=parameters)
|
|
34
|
+
return response.json()
|
|
@@ -0,0 +1,221 @@
|
|
|
1
|
+
import abc
|
|
2
|
+
import sys
|
|
3
|
+
from dataclasses import dataclass
|
|
4
|
+
from datetime import UTC, datetime
|
|
5
|
+
|
|
6
|
+
if sys.platform == "linux":
|
|
7
|
+
from dbus_next.aio import MessageBus
|
|
8
|
+
elif sys.platform == "win32":
|
|
9
|
+
from winsdk.windows.media.control import (
|
|
10
|
+
GlobalSystemMediaTransportControlsSessionManager as MediaManager,
|
|
11
|
+
)
|
|
12
|
+
from winsdk.windows.media.control import (
|
|
13
|
+
GlobalSystemMediaTransportControlsSessionPlaybackStatus as PlaybackStatus,
|
|
14
|
+
)
|
|
15
|
+
else:
|
|
16
|
+
MessageBus = None
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
class NoMPRISException(Exception):
|
|
20
|
+
def __init__(self):
|
|
21
|
+
super().__init__(
|
|
22
|
+
"MPRIS was not found on your system\nIf you are not on Linux, this error is intended"
|
|
23
|
+
)
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
class MPRISNotConnectedException(Exception):
|
|
27
|
+
def __init__(self):
|
|
28
|
+
super().__init__("The MPRIS client is not connected")
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
@dataclass
|
|
32
|
+
class TrackMetadata:
|
|
33
|
+
active: bool
|
|
34
|
+
title: str | None = None
|
|
35
|
+
album: str | None = None
|
|
36
|
+
artist: str | None = None
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
class BaseMediaProvider(abc.ABC):
|
|
40
|
+
@abc.abstractmethod
|
|
41
|
+
async def init(self):
|
|
42
|
+
"""Initialize the provider"""
|
|
43
|
+
|
|
44
|
+
async def get_metadata(self) -> TrackMetadata | None:
|
|
45
|
+
"""Fetch current song details."""
|
|
46
|
+
|
|
47
|
+
async def get_position(self) -> float | None:
|
|
48
|
+
"""Get current position"""
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
class LinuxMediaProvider(BaseMediaProvider):
|
|
52
|
+
def __init__(self):
|
|
53
|
+
self.bus = None
|
|
54
|
+
|
|
55
|
+
async def init(self):
|
|
56
|
+
if not self.bus:
|
|
57
|
+
return await self.connect()
|
|
58
|
+
|
|
59
|
+
async def connect(self):
|
|
60
|
+
if not MessageBus:
|
|
61
|
+
raise NoMPRISException
|
|
62
|
+
try:
|
|
63
|
+
# Connect to the Linux Session D-Bus
|
|
64
|
+
self.bus = await MessageBus().connect()
|
|
65
|
+
return True
|
|
66
|
+
except Exception: # noqa: BLE001
|
|
67
|
+
raise NoMPRISException
|
|
68
|
+
|
|
69
|
+
async def get_active_player_name(self) -> str | None:
|
|
70
|
+
if not self.bus:
|
|
71
|
+
raise MPRISNotConnectedException
|
|
72
|
+
|
|
73
|
+
introspection = await self.bus.introspect(
|
|
74
|
+
"org.freedesktop.DBus", "/org/freedesktop/DBus"
|
|
75
|
+
)
|
|
76
|
+
|
|
77
|
+
dbus_obj = self.bus.get_proxy_object(
|
|
78
|
+
"org.freedesktop.DBus", "/org/freedesktop/DBus", introspection
|
|
79
|
+
)
|
|
80
|
+
dbus_iface = dbus_obj.get_interface("org.freedesktop.DBus")
|
|
81
|
+
names = await dbus_iface.call_list_names()
|
|
82
|
+
|
|
83
|
+
for name in names:
|
|
84
|
+
if name.startswith("org.mpris.MediaPlayer2."):
|
|
85
|
+
return name
|
|
86
|
+
return None
|
|
87
|
+
|
|
88
|
+
async def get_metadata(self) -> TrackMetadata:
|
|
89
|
+
player_name = await self.get_active_player_name()
|
|
90
|
+
if not player_name:
|
|
91
|
+
return TrackMetadata(active=False)
|
|
92
|
+
|
|
93
|
+
introspection = await self.bus.introspect(
|
|
94
|
+
player_name, "/org/mpris/MediaPlayer2"
|
|
95
|
+
)
|
|
96
|
+
obj = self.bus.get_proxy_object(
|
|
97
|
+
player_name, "/org/mpris/MediaPlayer2", introspection
|
|
98
|
+
)
|
|
99
|
+
|
|
100
|
+
props = obj.get_interface("org.freedesktop.DBus.Properties")
|
|
101
|
+
|
|
102
|
+
metadata_variant = await props.call_get(
|
|
103
|
+
"org.mpris.MediaPlayer2.Player", "Metadata"
|
|
104
|
+
)
|
|
105
|
+
|
|
106
|
+
metadata = metadata_variant.value
|
|
107
|
+
|
|
108
|
+
artist_var = metadata.get("xesam:artist", [None])
|
|
109
|
+
title_var = metadata.get("xesam:title", None)
|
|
110
|
+
album_var = metadata.get("xesam:album", None)
|
|
111
|
+
|
|
112
|
+
title = title_var.value if title_var else None
|
|
113
|
+
album = album_var.value if album_var else None
|
|
114
|
+
artist = None
|
|
115
|
+
try:
|
|
116
|
+
artist = artist_var.value[0]
|
|
117
|
+
except AttributeError:
|
|
118
|
+
artist = artist_var[0]
|
|
119
|
+
|
|
120
|
+
return TrackMetadata(
|
|
121
|
+
active=True,
|
|
122
|
+
title=title,
|
|
123
|
+
album=album,
|
|
124
|
+
artist=artist,
|
|
125
|
+
)
|
|
126
|
+
|
|
127
|
+
async def get_position(self) -> float | None:
|
|
128
|
+
player_name = await self.get_active_player_name()
|
|
129
|
+
if not player_name:
|
|
130
|
+
return None
|
|
131
|
+
|
|
132
|
+
introspection = await self.bus.introspect(
|
|
133
|
+
player_name, "/org/mpris/MediaPlayer2"
|
|
134
|
+
)
|
|
135
|
+
obj = self.bus.get_proxy_object(
|
|
136
|
+
player_name, "/org/mpris/MediaPlayer2", introspection
|
|
137
|
+
)
|
|
138
|
+
props = obj.get_interface("org.freedesktop.DBus.Properties")
|
|
139
|
+
position_variant = await props.call_get(
|
|
140
|
+
"org.mpris.MediaPlayer2.Player", "Position"
|
|
141
|
+
)
|
|
142
|
+
|
|
143
|
+
position = getattr(position_variant, "value", position_variant)
|
|
144
|
+
return position / 1_000_000.0
|
|
145
|
+
|
|
146
|
+
|
|
147
|
+
class WindowsMediaProvider(BaseMediaProvider):
|
|
148
|
+
def __init__(self):
|
|
149
|
+
self.manager = None
|
|
150
|
+
|
|
151
|
+
async def init(self):
|
|
152
|
+
if not self.manager:
|
|
153
|
+
await self.connect()
|
|
154
|
+
|
|
155
|
+
async def connect(self):
|
|
156
|
+
try:
|
|
157
|
+
self.manager = await MediaManager.request_async()
|
|
158
|
+
except OSError:
|
|
159
|
+
self.manager = None
|
|
160
|
+
|
|
161
|
+
async def get_metadata(self) -> TrackMetadata | None:
|
|
162
|
+
session = self.manager.get_current_session()
|
|
163
|
+
if not session:
|
|
164
|
+
return TrackMetadata(active=False)
|
|
165
|
+
|
|
166
|
+
try:
|
|
167
|
+
info = await session.try_get_media_properties_async()
|
|
168
|
+
except PermissionError:
|
|
169
|
+
return TrackMetadata(active=False)
|
|
170
|
+
|
|
171
|
+
return TrackMetadata(
|
|
172
|
+
active=True,
|
|
173
|
+
title=info.title,
|
|
174
|
+
album=info.album_title,
|
|
175
|
+
artist=info.artist,
|
|
176
|
+
)
|
|
177
|
+
|
|
178
|
+
async def get_position(self) -> float | None:
|
|
179
|
+
session = self.manager.get_current_session()
|
|
180
|
+
if not session:
|
|
181
|
+
return None
|
|
182
|
+
|
|
183
|
+
timeline = session.get_timeline_properties()
|
|
184
|
+
playback_info = session.get_playback_info()
|
|
185
|
+
|
|
186
|
+
base_position = timeline.position.total_seconds()
|
|
187
|
+
|
|
188
|
+
if playback_info.playback_status != PlaybackStatus.PLAYING:
|
|
189
|
+
return base_position
|
|
190
|
+
|
|
191
|
+
last_updated = timeline.last_updated_time
|
|
192
|
+
now = datetime.now(UTC)
|
|
193
|
+
elapsed = (now - last_updated).total_seconds()
|
|
194
|
+
|
|
195
|
+
rate = getattr(playback_info, "playback_rate", None)
|
|
196
|
+
if rate is None:
|
|
197
|
+
rate = 1.0
|
|
198
|
+
current_position = base_position + (elapsed * rate)
|
|
199
|
+
|
|
200
|
+
end_time = timeline.end_time.total_seconds()
|
|
201
|
+
return min(current_position, end_time)
|
|
202
|
+
|
|
203
|
+
|
|
204
|
+
class PlatformNotSupportedError(Exception):
|
|
205
|
+
def __init__(self, platform: str) -> None:
|
|
206
|
+
super().__init__(f"Platform not supported: {platform}")
|
|
207
|
+
|
|
208
|
+
|
|
209
|
+
_cached_provider: BaseMediaProvider | None = None
|
|
210
|
+
|
|
211
|
+
|
|
212
|
+
def get_provider() -> BaseMediaProvider:
|
|
213
|
+
global _cached_provider
|
|
214
|
+
if _cached_provider is None:
|
|
215
|
+
if sys.platform == "linux":
|
|
216
|
+
_cached_provider = LinuxMediaProvider()
|
|
217
|
+
elif sys.platform == "win32":
|
|
218
|
+
_cached_provider = WindowsMediaProvider()
|
|
219
|
+
else:
|
|
220
|
+
raise PlatformNotSupportedError(sys.platform)
|
|
221
|
+
return _cached_provider
|
|
@@ -0,0 +1,240 @@
|
|
|
1
|
+
import argparse
|
|
2
|
+
import bisect
|
|
3
|
+
import random
|
|
4
|
+
import re
|
|
5
|
+
from array import array
|
|
6
|
+
from typing import ClassVar
|
|
7
|
+
|
|
8
|
+
from textual.app import App, Binding, ComposeResult
|
|
9
|
+
from textual.widgets import Label
|
|
10
|
+
|
|
11
|
+
from . import lrclib, monitor
|
|
12
|
+
|
|
13
|
+
current_lyrics: array[str] | None
|
|
14
|
+
current_timestamps: array[float] | None
|
|
15
|
+
current_infos: array | None
|
|
16
|
+
trackid: str | None = None
|
|
17
|
+
active: bool
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
async def get_lyrics(title, artist, album: str | None = None):
|
|
21
|
+
lyrics = await lrclib.get_lyrics(title=title, artist=artist, album=album)
|
|
22
|
+
if "syncedLyrics" in lyrics:
|
|
23
|
+
return {"lyrics": lyrics["syncedLyrics"]}
|
|
24
|
+
else:
|
|
25
|
+
return None
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
async def get_metadata():
|
|
29
|
+
provider = monitor.get_provider()
|
|
30
|
+
await provider.init()
|
|
31
|
+
metadata = await provider.get_metadata()
|
|
32
|
+
if metadata.artist == None and metadata.title == None and metadata.album == None:
|
|
33
|
+
run = False
|
|
34
|
+
else:
|
|
35
|
+
run = True
|
|
36
|
+
return [run, metadata.artist, metadata.title, metadata.album]
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
async def get_position():
|
|
40
|
+
provider = monitor.get_provider()
|
|
41
|
+
await provider.init()
|
|
42
|
+
position = await provider.get_position()
|
|
43
|
+
return position
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
async def check_if_new_song():
|
|
47
|
+
global trackid, active, current_lyrics, current_timestamps, current_infos
|
|
48
|
+
trackid = None if not isinstance(trackid, str) else trackid
|
|
49
|
+
metadata = await get_metadata()
|
|
50
|
+
if trackid == None or trackid != f"{metadata[1]}__{metadata[2]}":
|
|
51
|
+
if metadata[0] == True:
|
|
52
|
+
trackid = f"{metadata[1]}__{metadata[2]}"
|
|
53
|
+
lyrics = await get_lyrics(metadata[2], metadata[1])
|
|
54
|
+
current_lyrics = []
|
|
55
|
+
current_timestamps = []
|
|
56
|
+
|
|
57
|
+
TIMESTAMP_REGEX = re.compile(r"\[(\d{1,2}):(\d{2})(?:\.(\d{2,3}))?\]")
|
|
58
|
+
try:
|
|
59
|
+
for i in lyrics["lyrics"].splitlines():
|
|
60
|
+
match = TIMESTAMP_REGEX.search(i)
|
|
61
|
+
|
|
62
|
+
if not match:
|
|
63
|
+
current_lyrics = None
|
|
64
|
+
break
|
|
65
|
+
|
|
66
|
+
# Transform the timestamp into a position in seconds (because playerctl only outputs the position in seconds)
|
|
67
|
+
seconds = (
|
|
68
|
+
int(match.group(1)) * 60
|
|
69
|
+
+ int(match.group(2))
|
|
70
|
+
+ int(match.group(3) or 0) / 100
|
|
71
|
+
)
|
|
72
|
+
current_timestamps.append(seconds)
|
|
73
|
+
|
|
74
|
+
# Check if the lyric is not empty and then append it
|
|
75
|
+
lyric = TIMESTAMP_REGEX.sub("", i).strip()
|
|
76
|
+
if not lyric:
|
|
77
|
+
lyric = random.choice(["♫", "♪"])
|
|
78
|
+
|
|
79
|
+
current_lyrics.append(lyric)
|
|
80
|
+
except AttributeError, TypeError:
|
|
81
|
+
current_lyrics = None
|
|
82
|
+
|
|
83
|
+
active = True
|
|
84
|
+
current_infos = [metadata[1], metadata[2]]
|
|
85
|
+
|
|
86
|
+
else:
|
|
87
|
+
current_timestamps = None
|
|
88
|
+
current_infos = None
|
|
89
|
+
trackid = None
|
|
90
|
+
current_lyrics = None
|
|
91
|
+
active = False
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
async def get_current_lyrics(show_infos: bool, line_count: int):
|
|
95
|
+
if active:
|
|
96
|
+
if current_lyrics == None:
|
|
97
|
+
return ["Can't find lyrics for this song"]
|
|
98
|
+
pos = float(await get_position())
|
|
99
|
+
|
|
100
|
+
index = bisect.bisect_right(current_timestamps, pos) - 1
|
|
101
|
+
|
|
102
|
+
prev_lines = []
|
|
103
|
+
for offset in range(line_count, 0, -1):
|
|
104
|
+
target_idx = index - offset
|
|
105
|
+
prev_lines.append(
|
|
106
|
+
current_lyrics[target_idx]
|
|
107
|
+
if 0 <= target_idx < len(current_lyrics)
|
|
108
|
+
else ""
|
|
109
|
+
)
|
|
110
|
+
|
|
111
|
+
curr_line = (
|
|
112
|
+
current_lyrics[index]
|
|
113
|
+
if 0 <= index < len(current_lyrics)
|
|
114
|
+
else (f"{current_infos[0]} - {current_infos[1]}" if show_infos else "")
|
|
115
|
+
)
|
|
116
|
+
|
|
117
|
+
next_lines = []
|
|
118
|
+
for offset in range(1, line_count + 1):
|
|
119
|
+
target_idx = index + offset
|
|
120
|
+
next_lines.append(
|
|
121
|
+
current_lyrics[target_idx]
|
|
122
|
+
if 0 <= target_idx < len(current_lyrics)
|
|
123
|
+
else ""
|
|
124
|
+
)
|
|
125
|
+
|
|
126
|
+
return [prev_lines, curr_line, next_lines]
|
|
127
|
+
else:
|
|
128
|
+
return ["No song is playing"]
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
async def get_thing_to_display(show_infos: bool, lines_count: int):
|
|
132
|
+
await check_if_new_song()
|
|
133
|
+
return await get_current_lyrics(show_infos, lines_count)
|
|
134
|
+
|
|
135
|
+
|
|
136
|
+
class ParoleApp(App):
|
|
137
|
+
BINDINGS: ClassVar[list[Binding]] = [
|
|
138
|
+
Binding("ctrl+c", "quit", "Quit", show=True, priority=True),
|
|
139
|
+
]
|
|
140
|
+
CSS_PATH = "textual.tcss"
|
|
141
|
+
|
|
142
|
+
def __init__(self, delay_ms: float, show_infos: bool, ansi: bool, line_count: int):
|
|
143
|
+
super().__init__()
|
|
144
|
+
self.delay_seconds = delay_ms / 1000.0
|
|
145
|
+
self.show_infos = show_infos
|
|
146
|
+
self.ansi_color = ansi
|
|
147
|
+
self.line_count = line_count
|
|
148
|
+
|
|
149
|
+
def compose(self) -> ComposeResult:
|
|
150
|
+
if self.line_count > 0:
|
|
151
|
+
for i in range(self.line_count):
|
|
152
|
+
yield Label(
|
|
153
|
+
" ", id=f"before-label-{i}", classes="secundary-label before-label"
|
|
154
|
+
)
|
|
155
|
+
yield Label("Loading...", id="main-label")
|
|
156
|
+
if self.line_count > 0:
|
|
157
|
+
for i in range(self.line_count):
|
|
158
|
+
yield Label(
|
|
159
|
+
" ", id=f"after-label-{i}", classes="secundary-label after-label"
|
|
160
|
+
)
|
|
161
|
+
|
|
162
|
+
def on_mount(self) -> ComposeResult:
|
|
163
|
+
self.set_interval(self.delay_seconds, self.update_label)
|
|
164
|
+
|
|
165
|
+
async def update_label(self) -> None:
|
|
166
|
+
main_label = self.query_one("#main-label", Label)
|
|
167
|
+
before_labels = self.query(".before-label")
|
|
168
|
+
after_labels = self.query(".after-label")
|
|
169
|
+
|
|
170
|
+
lyrics = await get_thing_to_display(self.show_infos, self.line_count)
|
|
171
|
+
try:
|
|
172
|
+
before_lines, current_line, after_lines = lyrics
|
|
173
|
+
except ValueError:
|
|
174
|
+
current_line = lyrics[0]
|
|
175
|
+
before_lines = []
|
|
176
|
+
after_lines = []
|
|
177
|
+
|
|
178
|
+
main_label.update(current_line)
|
|
179
|
+
|
|
180
|
+
for label, text in zip(before_labels, before_lines):
|
|
181
|
+
label.display = True
|
|
182
|
+
label.update(text)
|
|
183
|
+
|
|
184
|
+
for label, text in zip(after_labels, after_lines):
|
|
185
|
+
label.display = True
|
|
186
|
+
label.update(text)
|
|
187
|
+
|
|
188
|
+
if len(before_lines) == 0:
|
|
189
|
+
for label in before_labels:
|
|
190
|
+
label.display = False
|
|
191
|
+
|
|
192
|
+
if len(after_lines) == 0:
|
|
193
|
+
for label in after_labels:
|
|
194
|
+
label.display = False
|
|
195
|
+
|
|
196
|
+
|
|
197
|
+
def main():
|
|
198
|
+
parser = argparse.ArgumentParser(
|
|
199
|
+
description="Parole is a TUI tool to display lyrics from the song you are currently playing"
|
|
200
|
+
)
|
|
201
|
+
parser.add_argument(
|
|
202
|
+
"--delay",
|
|
203
|
+
type=float,
|
|
204
|
+
default=100,
|
|
205
|
+
help="Delay at which the program will update lyrics",
|
|
206
|
+
)
|
|
207
|
+
parser.add_argument(
|
|
208
|
+
"--no-song-infos",
|
|
209
|
+
dest="show_infos",
|
|
210
|
+
action="store_false",
|
|
211
|
+
default=True,
|
|
212
|
+
help="Don't show song infos at the start of the song",
|
|
213
|
+
)
|
|
214
|
+
parser.add_argument(
|
|
215
|
+
"--no-ansi",
|
|
216
|
+
dest="ansi",
|
|
217
|
+
action="store_false",
|
|
218
|
+
default=True,
|
|
219
|
+
help="Don't use terminal colors",
|
|
220
|
+
)
|
|
221
|
+
parser.add_argument(
|
|
222
|
+
"--line-count",
|
|
223
|
+
dest="line_count",
|
|
224
|
+
default=1,
|
|
225
|
+
type=int,
|
|
226
|
+
help="Number of lyrics shown before and after the current one",
|
|
227
|
+
)
|
|
228
|
+
|
|
229
|
+
args = parser.parse_args()
|
|
230
|
+
app = ParoleApp(
|
|
231
|
+
delay_ms=args.delay,
|
|
232
|
+
show_infos=args.show_infos,
|
|
233
|
+
ansi=args.ansi,
|
|
234
|
+
line_count=args.line_count,
|
|
235
|
+
)
|
|
236
|
+
app.run()
|
|
237
|
+
|
|
238
|
+
|
|
239
|
+
if __name__ == "__main__":
|
|
240
|
+
main()
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
Screen {
|
|
2
|
+
align: center middle;
|
|
3
|
+
}
|
|
4
|
+
|
|
5
|
+
#main-label {
|
|
6
|
+
text-style: bold;
|
|
7
|
+
text-align: center;
|
|
8
|
+
content-align: center middle;
|
|
9
|
+
width: 100%;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
.secundary-label {
|
|
13
|
+
opacity: 50%;
|
|
14
|
+
text-align: center;
|
|
15
|
+
content-align: center middle;
|
|
16
|
+
width: 100%;
|
|
17
|
+
}
|