lrclib-synced 0.3.0__py3-none-any.whl
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.
- lrclib_synced-0.3.0.dist-info/METADATA +61 -0
- lrclib_synced-0.3.0.dist-info/RECORD +8 -0
- lrclib_synced-0.3.0.dist-info/WHEEL +5 -0
- lrclib_synced-0.3.0.dist-info/licenses/LICENSE +21 -0
- lrclib_synced-0.3.0.dist-info/top_level.txt +1 -0
- synced_lyrics/__init__.py +161 -0
- synced_lyrics/local.py +113 -0
- synced_lyrics/yandex.py +83 -0
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: lrclib-synced
|
|
3
|
+
Version: 0.3.0
|
|
4
|
+
Summary: Time-synced song lyrics from LRCLIB with a Yandex Music fallback, checked against track length
|
|
5
|
+
Author: nowplaylab
|
|
6
|
+
License: MIT
|
|
7
|
+
Project-URL: Homepage, https://github.com/nowplaylab/synced-lyrics
|
|
8
|
+
Project-URL: Issues, https://github.com/nowplaylab/synced-lyrics/issues
|
|
9
|
+
Classifier: Programming Language :: Python :: 3
|
|
10
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
11
|
+
Classifier: Framework :: AsyncIO
|
|
12
|
+
Requires-Python: >=3.9
|
|
13
|
+
Description-Content-Type: text/markdown
|
|
14
|
+
License-File: LICENSE
|
|
15
|
+
Requires-Dist: aiohttp>=3.8
|
|
16
|
+
Dynamic: license-file
|
|
17
|
+
|
|
18
|
+
# synced-lyrics
|
|
19
|
+
|
|
20
|
+
**English** | [Русский](https://github.com/nowplaylab/synced-lyrics/blob/main/README.ru.md)
|
|
21
|
+
|
|
22
|
+
Song lyrics with a time for every line, for karaoke-style display. Looks on [LRCLIB](https://lrclib.net) first; if nothing is there, takes plain lyrics from Yandex Music (no account needed).
|
|
23
|
+
|
|
24
|
+
Timed lyrics are only accepted when the track length matches (within 4 s, or 2 s when the artist is unknown). Otherwise a remix or live version would show lines at the wrong moments; you get plain lyrics instead.
|
|
25
|
+
|
|
26
|
+
Made by [nowplaylab](https://github.com/nowplaylab) for [NowPlaying](https://github.com/nowplaylab/nowplaying-display).
|
|
27
|
+
|
|
28
|
+
## Install
|
|
29
|
+
|
|
30
|
+
```
|
|
31
|
+
pip install git+https://github.com/nowplaylab/synced-lyrics@v0.2.0
|
|
32
|
+
```
|
|
33
|
+
|
|
34
|
+
Python 3.9+, depends only on aiohttp.
|
|
35
|
+
|
|
36
|
+
## Use
|
|
37
|
+
|
|
38
|
+
```python
|
|
39
|
+
import aiohttp, synced_lyrics
|
|
40
|
+
|
|
41
|
+
async def main():
|
|
42
|
+
async with aiohttp.ClientSession(headers={'User-Agent': 'my-app/1.0'}) as http:
|
|
43
|
+
lyrics = await synced_lyrics.fetch(http, 'Blinding Lights', 'The Weeknd', duration_ms=200040)
|
|
44
|
+
for line in lyrics.synced: # [{'t': milliseconds, 'text': line}], empty if no timing
|
|
45
|
+
print(line['t'], line['text'])
|
|
46
|
+
print(lyrics.plain) # plain text, may exist without timing
|
|
47
|
+
print(lyrics.source) # 'lrclib', 'yandex' or ''
|
|
48
|
+
```
|
|
49
|
+
|
|
50
|
+
- `fetch()` never raises on network errors. Busy or failing services are retried a few times; if one still does not answer, you get `lyrics.complete == False`. Empty with `complete=True` means there really are no lyrics; empty with `complete=False` means "could not check, ask again later", so do not cache it for long.
|
|
51
|
+
- Titles with extras like `(feat. …)`, `(Prod. by …)` or ` - Remastered 2011` are searched again without them (`synced_lyrics.simplify_title()`).
|
|
52
|
+
- `use_yandex=False` turns the fallback off.
|
|
53
|
+
- `local_dirs=['~/Music/Lyrics']` checks your own files first, with no network: `Artist - Title.lrc`, `Title - Artist.lrc` or `Title.lrc` (a `.txt` works too, as plain lyrics). A `.lrc` with any name is found by its `[ti:]` and `[ar:]` tags. If the file has `[length:3:34]`, it is used only when the track length matches (within 4 s). `lyrics.source` is then `'file'`. Use `synced_lyrics.local.find()` and `read()` on their own if you like.
|
|
54
|
+
- `parse_lrc()` understands `[offset:±ms]` and drops per-word times (`<00:10.80>`), keeping the line.
|
|
55
|
+
- `synced_lyrics.parse_lrc(text)` parses any LRC text on its own.
|
|
56
|
+
- `synced_lyrics.yandex` has the search helpers: `find_tracks()`, `same_track()`, `plain_lyrics()`.
|
|
57
|
+
- LRCLIB asks clients to send their own User-Agent.
|
|
58
|
+
|
|
59
|
+
## Licence
|
|
60
|
+
|
|
61
|
+
MIT. Not affiliated with LRCLIB or Yandex.
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
lrclib_synced-0.3.0.dist-info/licenses/LICENSE,sha256=YEAebu59O4eQUftdMcKSnu2g4u1K4X2V-Usk2vETUzs,1090
|
|
2
|
+
synced_lyrics/__init__.py,sha256=fI_WtUm6VQi9CshPToQ3zTWk-f9_nCTvcKjA4KQFEAs,7805
|
|
3
|
+
synced_lyrics/local.py,sha256=fdwtp2NnD3lAQOSHF_-1dCtU3Oaj0xttpApVK79XFnc,4613
|
|
4
|
+
synced_lyrics/yandex.py,sha256=Z5NZCsTCEGDkkfFH1fSmOctt3nyYAeas2u8BAerWHfw,3786
|
|
5
|
+
lrclib_synced-0.3.0.dist-info/METADATA,sha256=YmTROWFqM1fphwbtqLPJHL7j13MQDtfgYthobtPcK8o,3261
|
|
6
|
+
lrclib_synced-0.3.0.dist-info/WHEEL,sha256=mOdXR9C3wMVov7cSY8PY-XCzo3SmmWCHvGQr5csJoao,91
|
|
7
|
+
lrclib_synced-0.3.0.dist-info/top_level.txt,sha256=N8GVwrdJJ0R8TAFUagTv5mP_9LnnUITuQO5WuMPIo6o,14
|
|
8
|
+
lrclib_synced-0.3.0.dist-info/RECORD,,
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 nowplaylab (tryshyzov and Claude)
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
synced_lyrics
|
|
@@ -0,0 +1,161 @@
|
|
|
1
|
+
"""Тексты песен со временем строк: LRCLIB (синхронный и обычный текст) и запасной — Яндекс Музыка.
|
|
2
|
+
|
|
3
|
+
lyrics = await synced_lyrics.fetch(http, 'Blinding Lights', 'The Weeknd', duration_ms=200040)
|
|
4
|
+
for line in lyrics.synced: # [{'t': мс, 'text': строка}] — пусто, если таймингов нет
|
|
5
|
+
...
|
|
6
|
+
lyrics.plain # обычный текст (может быть и без синхронного)
|
|
7
|
+
lyrics.source # 'file', 'lrclib', 'yandex' или ''
|
|
8
|
+
lyrics.complete # False — какой-то источник не ответил, стоит спросить позже
|
|
9
|
+
|
|
10
|
+
Свои файлы: fetch(..., local_dirs=['~/Music/Lyrics']) сначала ищет «Исполнитель - Название.lrc»
|
|
11
|
+
(или .txt) в этих папках, см. synced_lyrics.local.
|
|
12
|
+
|
|
13
|
+
Синхронный текст берётся, только если длительность совпала (±4 с; без исполнителя — ±2 с),
|
|
14
|
+
иначе строки разъедутся с песней (ремикс, live, другая версия).
|
|
15
|
+
|
|
16
|
+
Пустой результат с complete=True значит «текста правда нет»; с complete=False — «не смогли
|
|
17
|
+
проверить» (сеть, сервис перегружен). Такой результат не стоит запоминать надолго.
|
|
18
|
+
"""
|
|
19
|
+
import asyncio
|
|
20
|
+
import logging
|
|
21
|
+
import re
|
|
22
|
+
from dataclasses import dataclass, field
|
|
23
|
+
|
|
24
|
+
import aiohttp
|
|
25
|
+
|
|
26
|
+
from . import yandex
|
|
27
|
+
|
|
28
|
+
__version__ = '0.3.0'
|
|
29
|
+
__all__ = ['Lyrics', 'fetch', 'parse_lrc', 'simplify_title', 'local', 'LRCLIB']
|
|
30
|
+
|
|
31
|
+
log = logging.getLogger('synced_lyrics')
|
|
32
|
+
LRCLIB = 'https://lrclib.net/api'
|
|
33
|
+
NET_ERRORS = (aiohttp.ClientError, asyncio.TimeoutError, ValueError, OSError)
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
@dataclass
|
|
37
|
+
class Lyrics:
|
|
38
|
+
synced: list = field(default_factory=list) # [{'t': мс, 'text': строка}]
|
|
39
|
+
plain: str = ''
|
|
40
|
+
source: str = ''
|
|
41
|
+
complete: bool = True # все источники ответили
|
|
42
|
+
|
|
43
|
+
def __bool__(self):
|
|
44
|
+
return bool(self.synced or self.plain)
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def parse_lrc(text):
|
|
48
|
+
"""LRC → [{'t': мс, 'text': строка}] по времени. Строка с несколькими метками повторяется.
|
|
49
|
+
Метка [offset:±мс] сдвигает все строки (плюс — раньше, как в стандарте LRC).
|
|
50
|
+
Время отдельных слов <мм:сс.xx> внутри строки отбрасывается — остаётся текст строки."""
|
|
51
|
+
m = re.search(r'^\s*\[offset:\s*([+-]?\d+)\s*\]', text or '', re.M | re.I)
|
|
52
|
+
offset = int(m.group(1)) if m else 0
|
|
53
|
+
lines = []
|
|
54
|
+
for raw in (text or '').splitlines():
|
|
55
|
+
stamps = re.findall(r'\[(\d+):(\d+(?:\.\d+)?)\]', raw)
|
|
56
|
+
words = re.sub(r'<\d+:\d+(?:\.\d+)?>', '', re.sub(r'\[[^\]]*\]', '', raw))
|
|
57
|
+
words = re.sub(r'\s+', ' ', words).strip()
|
|
58
|
+
for mm, s in stamps:
|
|
59
|
+
lines.append({'t': max(0, int((int(mm) * 60 + float(s)) * 1000) - offset), 'text': words})
|
|
60
|
+
return sorted(lines, key=lambda l: l['t'])
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def simplify_title(title):
|
|
64
|
+
"""Название без приписок: (feat. …), (Prod. by …), [Explicit], « - Remastered 2011» и т. п."""
|
|
65
|
+
t = re.sub(r'\s*[\(\[][^\)\]]*[\)\]]', '', title or '')
|
|
66
|
+
t = re.sub(r'\s+[-–—]\s+[^-–—]*\b(remaster\w*|live|version|edit|mix|mono|stereo)\b.*$', '', t, flags=re.I)
|
|
67
|
+
return t.strip() or (title or '').strip()
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
async def _get_json(http, url, params, tries=3):
|
|
71
|
+
"""(ok, данные). 200 — данные; 400/404 — (True, None): ответили, что нет; прочее — повтор."""
|
|
72
|
+
for i in range(tries):
|
|
73
|
+
try:
|
|
74
|
+
async with http.get(url, params=params) as r:
|
|
75
|
+
if r.status == 200:
|
|
76
|
+
return True, await r.json(content_type=None)
|
|
77
|
+
if r.status in (400, 404):
|
|
78
|
+
return True, None
|
|
79
|
+
log.info('%s -> %s', url, r.status)
|
|
80
|
+
except NET_ERRORS as e:
|
|
81
|
+
log.info('%s: %s', url, e)
|
|
82
|
+
if i < tries - 1:
|
|
83
|
+
await asyncio.sleep(0.6 * (i + 1) ** 2)
|
|
84
|
+
return False, None
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
def _pick(cands, artist, duration_ms):
|
|
88
|
+
def diff(c):
|
|
89
|
+
return abs((c.get('duration') or 0) * 1000 - duration_ms) if duration_ms and c.get('duration') else None
|
|
90
|
+
|
|
91
|
+
limit = 4000 if artist else 2000 # без исполнителя — строже
|
|
92
|
+
synced = [c for c in cands if c.get('syncedLyrics') and
|
|
93
|
+
(diff(c) is None and duration_ms == 0 and artist or diff(c) is not None and diff(c) <= limit)]
|
|
94
|
+
synced.sort(key=lambda c: diff(c) or 0)
|
|
95
|
+
plain = [c for c in cands if c.get('plainLyrics') and (artist or (diff(c) is not None and diff(c) <= 5000))]
|
|
96
|
+
plain.sort(key=lambda c: diff(c) if diff(c) is not None else 10 ** 9)
|
|
97
|
+
if synced:
|
|
98
|
+
return Lyrics(parse_lrc(synced[0]['syncedLyrics']), synced[0].get('plainLyrics') or '', 'lrclib')
|
|
99
|
+
if plain:
|
|
100
|
+
return Lyrics([], plain[0]['plainLyrics'], 'lrclib')
|
|
101
|
+
return Lyrics()
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
async def from_lrclib(http, title, artist='', album='', duration_ms=0, base=LRCLIB):
|
|
105
|
+
"""Lyrics из LRCLIB; complete=False, если LRCLIB не ответил."""
|
|
106
|
+
complete = True
|
|
107
|
+
short = simplify_title(title)
|
|
108
|
+
best = Lyrics()
|
|
109
|
+
for t in [title] + ([short] if short != title else []):
|
|
110
|
+
q = {'track_name': t, 'artist_name': artist}
|
|
111
|
+
cands = []
|
|
112
|
+
if artist and duration_ms: # /get без длительности LRCLIB не принимает
|
|
113
|
+
ok, d = await _get_json(http, f'{base}/get', dict(q, album_name=album, duration=round(duration_ms / 1000)))
|
|
114
|
+
complete &= ok
|
|
115
|
+
if isinstance(d, dict):
|
|
116
|
+
cands.append(d)
|
|
117
|
+
ok, d = await _get_json(http, f'{base}/search', q if artist else {'q': t})
|
|
118
|
+
complete &= ok
|
|
119
|
+
if isinstance(d, list):
|
|
120
|
+
cands += d
|
|
121
|
+
got = _pick(cands, artist, duration_ms)
|
|
122
|
+
if got.synced:
|
|
123
|
+
return got
|
|
124
|
+
if got and not best:
|
|
125
|
+
best = got
|
|
126
|
+
best.complete = complete
|
|
127
|
+
return best
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
async def fetch(http, title, artist='', album='', duration_ms=0, use_yandex=True, lrclib=LRCLIB, local_dirs=None):
|
|
131
|
+
"""Найти текст. http — aiohttp.ClientSession (лучше со своим User-Agent). Ошибки сети не бросает:
|
|
132
|
+
если источник не ответил даже после повторов, у результата complete=False.
|
|
133
|
+
local_dirs — папки со своими .lrc/.txt: проверяются первыми, без сети (source='file')."""
|
|
134
|
+
if local_dirs:
|
|
135
|
+
try:
|
|
136
|
+
path = await asyncio.get_running_loop().run_in_executor(
|
|
137
|
+
None, local.find, local_dirs, title, artist, duration_ms)
|
|
138
|
+
if path:
|
|
139
|
+
got = local.read(path)
|
|
140
|
+
if got:
|
|
141
|
+
return got
|
|
142
|
+
except OSError as e:
|
|
143
|
+
log.warning('local lyrics: %s', e)
|
|
144
|
+
try:
|
|
145
|
+
result = await from_lrclib(http, title, artist, album, duration_ms, lrclib)
|
|
146
|
+
except NET_ERRORS as e:
|
|
147
|
+
log.warning('lrclib: %s', e)
|
|
148
|
+
result = Lyrics(complete=False)
|
|
149
|
+
if not result and use_yandex:
|
|
150
|
+
try:
|
|
151
|
+
text, ok = await yandex.plain_lyrics_checked(http, title, artist, duration_ms)
|
|
152
|
+
except NET_ERRORS as e:
|
|
153
|
+
log.warning('yandex lyrics: %s', e)
|
|
154
|
+
text, ok = '', False
|
|
155
|
+
if text:
|
|
156
|
+
return Lyrics([], text, 'yandex')
|
|
157
|
+
result.complete = result.complete and ok
|
|
158
|
+
return result
|
|
159
|
+
|
|
160
|
+
|
|
161
|
+
from . import local # noqa: E402 (local использует Lyrics и parse_lrc отсюда)
|
synced_lyrics/local.py
ADDED
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
"""Свои тексты из файлов: .lrc (со временем строк) или .txt (просто текст) в папках на компьютере.
|
|
2
|
+
|
|
3
|
+
path = synced_lyrics.local.find(['~/Music/Lyrics'], 'Ночной маршрут', 'Кассета', duration_ms=214000)
|
|
4
|
+
lyrics = synced_lyrics.local.read(path)
|
|
5
|
+
|
|
6
|
+
Как файл находится (без учёта регистра, лишних пробелов и знаков):
|
|
7
|
+
* по имени: «Исполнитель - Название.lrc», «Название - Исполнитель.lrc» или «Название.lrc»;
|
|
8
|
+
* по меткам внутри LRC: [ti:Название] и [ar:Исполнитель].
|
|
9
|
+
Если в LRC есть [length:3:34] и длина трека известна, файл берётся, только когда они сходятся (±4 с).
|
|
10
|
+
Папки просматриваются с подпапками (до двух уровней), .lrc важнее .txt.
|
|
11
|
+
"""
|
|
12
|
+
import os
|
|
13
|
+
import re
|
|
14
|
+
|
|
15
|
+
from . import Lyrics, parse_lrc, simplify_title
|
|
16
|
+
|
|
17
|
+
EXTS = ('.lrc', '.txt')
|
|
18
|
+
MAX_FILES = 5000 # не читать бесконечно огромную папку
|
|
19
|
+
LENGTH_LIMIT = 4000
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def _norm(text):
|
|
23
|
+
text = (text or '').lower().replace('ё', 'е')
|
|
24
|
+
return re.sub(r'[\W_]+', ' ', text).strip()
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def _read_text(path):
|
|
28
|
+
with open(path, 'rb') as f:
|
|
29
|
+
raw = f.read(2_000_000)
|
|
30
|
+
for enc in ('utf-8-sig', 'cp1251'):
|
|
31
|
+
try:
|
|
32
|
+
text = raw.decode(enc)
|
|
33
|
+
break
|
|
34
|
+
except UnicodeDecodeError:
|
|
35
|
+
continue
|
|
36
|
+
else:
|
|
37
|
+
text = raw.decode('utf-8', 'replace')
|
|
38
|
+
return text.replace('\r\n', '\n').replace('\r', '\n') # файлы из Windows и старых Mac
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def _tags(text):
|
|
42
|
+
"""Метки LRC из шапки: {'ti': ..., 'ar': ..., 'length': ...}."""
|
|
43
|
+
return {k.lower(): v.strip() for k, v in re.findall(r'^\s*\[([a-zA-Z]+):([^\]]*)\]\s*$', text, re.M)}
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def _length_ms(value):
|
|
47
|
+
m = re.fullmatch(r'\s*(\d+):(\d+(?:\.\d+)?)\s*', value or '')
|
|
48
|
+
return int((int(m.group(1)) * 60 + float(m.group(2))) * 1000) if m else 0
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def _files(dirs):
|
|
52
|
+
n = 0
|
|
53
|
+
for d in dirs or ():
|
|
54
|
+
root = os.path.expanduser(str(d))
|
|
55
|
+
if not os.path.isdir(root):
|
|
56
|
+
continue
|
|
57
|
+
base_depth = root.rstrip(os.sep).count(os.sep)
|
|
58
|
+
for cur, subdirs, files in os.walk(root):
|
|
59
|
+
if cur.count(os.sep) - base_depth >= 2:
|
|
60
|
+
subdirs[:] = []
|
|
61
|
+
for name in files:
|
|
62
|
+
if name.lower().endswith(EXTS):
|
|
63
|
+
yield os.path.join(cur, name)
|
|
64
|
+
n += 1
|
|
65
|
+
if n >= MAX_FILES:
|
|
66
|
+
return
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def find(dirs, title, artist='', duration_ms=0):
|
|
70
|
+
"""Путь к подходящему файлу или None."""
|
|
71
|
+
titles = {_norm(title), _norm(simplify_title(title))} - {''}
|
|
72
|
+
if not titles:
|
|
73
|
+
return None
|
|
74
|
+
art = _norm(artist)
|
|
75
|
+
names = set()
|
|
76
|
+
for t in titles:
|
|
77
|
+
names.add(t)
|
|
78
|
+
if art:
|
|
79
|
+
names.update({f'{art} {t}', f'{t} {art}'})
|
|
80
|
+
best = None # (приоритет, путь): меньше — лучше
|
|
81
|
+
for path in _files(dirs):
|
|
82
|
+
stem, ext = os.path.splitext(os.path.basename(path))
|
|
83
|
+
rank = 0 if ext.lower() == '.lrc' else 1
|
|
84
|
+
hit = _norm(stem) in names
|
|
85
|
+
if not hit and ext.lower() == '.lrc':
|
|
86
|
+
try:
|
|
87
|
+
tags = _tags(_read_text(path))
|
|
88
|
+
except OSError:
|
|
89
|
+
continue
|
|
90
|
+
hit = _norm(tags.get('ti')) in titles and (not art or not tags.get('ar') or _norm(tags.get('ar')) == art)
|
|
91
|
+
rank += 2 # совпадение по меткам — после совпадения по имени
|
|
92
|
+
if not hit:
|
|
93
|
+
continue
|
|
94
|
+
if ext.lower() == '.lrc' and duration_ms:
|
|
95
|
+
try:
|
|
96
|
+
length = _length_ms(_tags(_read_text(path)).get('length'))
|
|
97
|
+
except OSError:
|
|
98
|
+
continue
|
|
99
|
+
if length and abs(length - duration_ms) > LENGTH_LIMIT:
|
|
100
|
+
continue
|
|
101
|
+
if best is None or rank < best[0]:
|
|
102
|
+
best = (rank, path)
|
|
103
|
+
return best[1] if best else None
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
def read(path):
|
|
107
|
+
"""Lyrics из файла: .lrc — со временем строк, .txt — обычный текст. source='file'."""
|
|
108
|
+
text = _read_text(path)
|
|
109
|
+
if path.lower().endswith('.lrc'):
|
|
110
|
+
synced = parse_lrc(text)
|
|
111
|
+
plain = '\n'.join(l['text'] for l in synced) if synced else re.sub(r'\[[^\]]*\]', '', text).strip()
|
|
112
|
+
return Lyrics(synced, plain, 'file')
|
|
113
|
+
return Lyrics([], text.strip(), 'file')
|
synced_lyrics/yandex.py
ADDED
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
"""Открытый API Яндекс Музыки (без входа в аккаунт): поиск трека и обычный текст песни."""
|
|
2
|
+
import asyncio
|
|
3
|
+
import re
|
|
4
|
+
|
|
5
|
+
import aiohttp
|
|
6
|
+
|
|
7
|
+
YM_API = 'https://api.music.yandex.net'
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
def norm(s):
|
|
11
|
+
s = (s or '').lower().replace('ё', 'е')
|
|
12
|
+
s = re.sub(r'\s*[\(\[][^\)\]]*[\)\]]', '', s) # (feat. …), [Explicit] и т. п.
|
|
13
|
+
return re.sub(r'[^\w]+', ' ', s).strip()
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def artist_set(artist):
|
|
17
|
+
return {norm(a) for a in re.split(r',|&| feat\.? | ft\.? | x ', artist or '', flags=re.I) if a.strip()}
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def same_track(title, artist, dur_ms, cand_title, cand_artists, cand_dur_ms, tolerance_ms=6000):
|
|
21
|
+
"""Тот же ли трек: название совпало, хотя бы один исполнитель общий, длительность рядом."""
|
|
22
|
+
if norm(cand_title) != norm(title):
|
|
23
|
+
return False
|
|
24
|
+
want = artist_set(artist)
|
|
25
|
+
have = set().union(*(artist_set(a) for a in cand_artists)) if cand_artists else set()
|
|
26
|
+
if want and not (have & want): # у разных сервисов дуэт бывает и одной строкой, и списком
|
|
27
|
+
return False
|
|
28
|
+
return not (dur_ms and cand_dur_ms and abs(cand_dur_ms - dur_ms) > tolerance_ms)
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
async def call(http, path, params=None, tries=3):
|
|
32
|
+
"""(ok, result). ok=False — Яндекс так и не ответил (сеть, перегрузка); ok=True и None — ответил, что нет."""
|
|
33
|
+
for i in range(tries): # Яндекс иногда отвечает пустотой на первый запрос
|
|
34
|
+
try:
|
|
35
|
+
async with http.get(f'{YM_API}/{path}', params=params) as r:
|
|
36
|
+
if r.status == 404:
|
|
37
|
+
return True, None
|
|
38
|
+
if r.status == 200:
|
|
39
|
+
try:
|
|
40
|
+
return True, ((await r.json(content_type=None)) or {}).get('result')
|
|
41
|
+
except ValueError:
|
|
42
|
+
pass
|
|
43
|
+
except (aiohttp.ClientError, asyncio.TimeoutError, OSError):
|
|
44
|
+
pass
|
|
45
|
+
if i < tries - 1:
|
|
46
|
+
await asyncio.sleep(1)
|
|
47
|
+
return False, None
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
async def api(http, path, params=None):
|
|
51
|
+
"""result из ответа или None."""
|
|
52
|
+
return (await call(http, path, params))[1]
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def _matching(res, title, artist, dur_ms):
|
|
56
|
+
tracks = ((res or {}).get('tracks') or {}).get('results') or []
|
|
57
|
+
return [t for t in tracks if same_track(title, artist, dur_ms, t.get('title'),
|
|
58
|
+
[a.get('name') for a in t.get('artists') or []], t.get('durationMs'))]
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
async def find_tracks(http, title, artist, dur_ms):
|
|
62
|
+
"""Треки из поиска Яндекс Музыки, которые совпали с играющим."""
|
|
63
|
+
res = await api(http, 'search', {'text': f'{artist} {title}'.strip(), 'type': 'track', 'page': '0'})
|
|
64
|
+
return _matching(res, title, artist, dur_ms)
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
async def plain_lyrics_checked(http, title, artist, dur_ms):
|
|
68
|
+
"""(текст или '', ok). ok=False — Яндекс не ответил, «текста нет» утверждать нельзя."""
|
|
69
|
+
ok, res = await call(http, 'search', {'text': f'{artist} {title}'.strip(), 'type': 'track', 'page': '0'})
|
|
70
|
+
if not ok:
|
|
71
|
+
return '', False
|
|
72
|
+
for t in _matching(res, title, artist, dur_ms)[:2]:
|
|
73
|
+
ok2, sup = await call(http, f'tracks/{t["id"]}/supplement')
|
|
74
|
+
ok = ok and ok2
|
|
75
|
+
text = ((sup or {}).get('lyrics') or {}).get('fullLyrics') or ''
|
|
76
|
+
if text.strip():
|
|
77
|
+
return text, True
|
|
78
|
+
return '', ok
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
async def plain_lyrics(http, title, artist, dur_ms):
|
|
82
|
+
"""Обычный текст песни (без таймингов) или ''."""
|
|
83
|
+
return (await plain_lyrics_checked(http, title, artist, dur_ms))[0]
|