Compare commits

...

7 commits

Author SHA1 Message Date
b6761c2824 make deps 2023-02-08 15:01:47 +04:00
74ccfec742 PEP8, MyPy, Pylint; Makefile glob bugfix 2023-02-08 15:00:20 +04:00
426bd118aa Makefile 2023-02-08 14:43:55 +04:00
0930d20224 DRY principle 2023-02-08 14:40:20 +04:00
9687313cb4 Manual info correction, input() using readline 2023-02-08 14:07:51 +04:00
450421ce0d ParseError -> manual input 2023-02-07 20:52:08 +04:00
1728effd97 Disabled debug option 2023-02-07 20:09:05 +04:00
6 changed files with 392 additions and 137 deletions

7
.gitignore vendored
View file

@ -1,5 +1,10 @@
files/ files/
convert/ convert/
tagged/ tagged/
.vscode/
lyrics.txt lyrics.txt
input
__pycache__/
.mypy_cache/
.vscode/
.idea/

View file

@ -11,14 +11,14 @@ import mimetypes
import subprocess import subprocess
from typing import TypedDict from typing import TypedDict
from typing import Optional from typing import Optional, Any
import re import re
import requests import requests
from bs4 import BeautifulSoup from bs4 import BeautifulSoup # type: ignore
from mutagen.id3 import ID3 from mutagen.id3 import ID3 # type: ignore
from mutagen.id3 import TPE1, TIT2, TALB from mutagen.id3 import TPE1, TIT2, TALB
from mutagen.id3 import TYER, TRCK from mutagen.id3 import TYER, TRCK
from mutagen.id3 import USLT, APIC from mutagen.id3 import USLT, APIC
@ -53,15 +53,30 @@ class ParseResult(TypedDict):
class ParseError(Exception): class ParseError(Exception):
EDIT = 'edit'
def __init__(self, parsing_obj: str) -> None: def __init__(self, parsing_obj: str) -> None:
super().__init__( super().__init__(
f'Unable to parse {parsing_obj}' f'Unable to parse {parsing_obj}'
) )
self.parsing_obj = parsing_obj
parsed = ParseResult(
title='', artist='',
album='', year=0,
track_no=0, tracks=0,
lyrics='',
cover=None,
cover_mime=None,
)
def main() -> None: def main() -> None:
global parsed
copy = int(sys.argv[1]) == 1 copy = int(sys.argv[1]) == 1
file = sys.argv[2] file = sys.argv[2]
@ -76,25 +91,67 @@ def main() -> None:
print('Title:', title) print('Title:', title)
correct = input().strip() correct = input().strip()
parsed: Optional[ParseResult] = None
if correct == '!--': if correct == '!--':
parsed = manual_info_input() manual_info_input()
else: else:
if correct != '': if correct != '':
title = correct.lower() title = correct.lower()
url = search_azurl(title)
print(url)
parsed = parse_azlyrics(url)
#print(parsed) try:
tagmp3(file, parsed, copy) url = search_azurl(title)
print(url)
parse_azlyrics(url)
except Exception as err:
print(err)
# pylint: disable=no-member
if isinstance(err, ParseError) \
and err.parsing_obj == ParseError.EDIT:
pass
# pylint: enable=no-member
else:
print(
'In most cases, this error means that '
'the script have received some incorrect data, '
'so you should enter song info manually.'
)
manual_info_input(False)
tagmp3(file, copy)
# pylint: disable=redefined-builtin
def input(msg: str = '', def_: Any = '') -> str:
subprocess.call(
(
f'read -e -r -i "{def_}" -p "{msg}" input; '
'echo -n "$input" >./input'
),
shell=True,
executable='bash',
)
try:
with open('./input', 'rt', encoding='utf-8') as f:
return f.read() \
.removesuffix('\n') \
.removesuffix('\r')
except Exception:
return def_
# pylint: enable=redefined-builtin
def input_num(msg: str, def_: int = 0) -> int: def input_num(msg: str, def_: int = 0) -> int:
try: try:
return int(input(msg)) return int(input(msg, def_))
except ValueError: except ValueError:
return def_ return def_
@ -135,46 +192,37 @@ def conv_title(file: str) -> str:
def search_azurl(title: str) -> str: def search_azurl(title: str) -> str:
print('Searching...') print('Searching...')
page = session.get( page = session.get(
'https://searx.dc09.ru/search', 'https://searx.dc09.ru/search',
params={ params={ # type: ignore
'q': f'{title} site:azlyrics.com', 'q': f'{title} site:azlyrics.com',
'category_general': 1,
'language': 'ru-RU', 'language': 'ru-RU',
'time_range': '',
'safesearch': 0, 'safesearch': 0,
'theme': 'simple',
}, },
) )
soup = BeautifulSoup(page.text, 'html.parser') soup = BeautifulSoup(page.text, 'html.parser')
link = soup.select_one( link = soup.select_one(
'div#urls>article>h3>a[href*="azlyrics.com/lyrics/"]' 'div#urls>article>h3>a'
'[href*="azlyrics.com/lyrics/"]'
) )
if link is None: if link is None:
raise ParseError('song URL') raise ParseError('song URL')
return str(link.get('href')) return str(link.get('href'))
def parse_azlyrics(link: str) -> ParseResult: def parse_azlyrics(link: str) -> None:
result = ParseResult( global parsed
title='', artist='',
album='', year=0,
track_no=0, tracks=0,
lyrics='',
cover=None,
cover_mime=None,
)
print('Please wait...') print('Please wait...')
page = session.get(link) page = session.get(link)
soup = BeautifulSoup(page.text, 'html.parser') soup = BeautifulSoup(page.text, 'html.parser')
lyrics = soup.select_one( lyrics = soup.select_one(
f'{LYRICS_ROW}>div' f'{LYRICS_ROW}>div'
':not(.div-share)' ':not(.div-share)'
@ -183,127 +231,107 @@ def parse_azlyrics(link: str) -> ParseResult:
) )
if lyrics is None: if lyrics is None:
raise ParseError('song lyrics') raise ParseError('song lyrics')
result['lyrics'] = lyrics.get_text().strip() parsed['lyrics'] = lyrics.get_text().strip()
artist_elem = soup.select_one(f'{LYRICS_ROW}>.lyricsh>h2') lyrics_file = Path('.') / 'lyrics.txt'
if artist_elem is None: with lyrics_file.open('wt', encoding='utf-8') as f:
print('Unable to parse artist name') f.write(parsed['lyrics'])
result['artist'] = input('Enter the artist name: ')
else:
result['artist'] = artist_elem.get_text() \
.removesuffix(' Lyrics') \
.strip()
title_elem = soup.select_one(f'{LYRICS_ROW}>b') title_elem = soup.select_one(f'{LYRICS_ROW}>b')
if title_elem is None: if title_elem is None:
print('Unable to parse song title') raise ParseError('song title')
result['title'] = input('Enter the title: ') parsed['title'] = title_elem.get_text().strip('" ')
else:
result['title'] = title_elem.get_text().strip('" ') artist_elem = soup.select_one(f'{LYRICS_ROW}>.lyricsh>h2')
if artist_elem is None:
raise ParseError('artist name')
parsed['artist'] = artist_elem.get_text() \
.removesuffix(' Lyrics') \
.strip()
album_blocks = soup.select('.songinalbum_title') album_blocks = soup.select('.songinalbum_title')
album = None album = None
if len(album_blocks) > 1: if len(album_blocks) > 1:
album = album_blocks[-2] album = album_blocks[-2]
elif len(album_blocks) > 0: elif len(album_blocks) > 0:
album = album_blocks[0] album = album_blocks[0]
if album is None:
album_re = None
else: else:
album_re = re.search( raise ParseError('album name')
r'album:\s*"(.+?)"\s*\((\d+)\)',
album.get_text() album_re = re.search(
r'album:\s*"(.+?)"\s*\((\d+)\)',
album.get_text()
)
if album_re is None:
raise ParseError('album name')
parsed['album'] = album_re[1]
parsed['year'] = int(album_re[2])
cover = album.select_one('img.album-image')
if cover is not None:
cover_url = str(cover.get('src'))
if cover_url.startswith('/'):
cover_url = BASEURL + cover_url
req = session.get(cover_url)
parsed['cover'] = req.content
parsed['cover_mime'] = req.headers.get(
'Content-Type', 'image/jpeg'
) )
if album_re is None: tracklist_elem = soup.select_one('.songlist-panel')
print('Unable to parse album name') if tracklist_elem is not None:
result['album'] = input('Enter the album name: ')
result['year'] = input_num('Enter the release year: ')
result['track_no'] = input_num('This is the track #')
result['tracks'] = input_num('Number of tracks in the album: ')
cover = input('Insert an album cover? [Y/n] ') tracklist = tracklist_elem.select(
if cover.lower() not in ('n','н'): '.listalbum-item'
try: )
print( parsed['tracks'] = len(tracklist)
'Download the cover and enter its path:',
'(relative path is not recommended)',
sep='\n',
)
cover_file = Path(input().strip())
with cover_file.open('rb') as f: current_url = re.search(
result['cover'] = f.read() r'/(lyrics/.+?\.html)',
link,
)
result['cover_mime'] = ( parsed['track_no'] = 0
mimetypes.guess_type(cover_file)[0] if current_url is not None:
or 'image/jpeg' for i, track in enumerate(tracklist):
)
except Exception as err:
logging.exception(err)
else: track_url = track.select_one('a')
result['album'] = album_re[1] if track_url is None:
result['year'] = int(album_re[2]) continue
assert album is not None track_href = str(track_url.get('href'))
cover = album.select_one('img.album-image') if current_url[0] in track_href:
parsed['track_no'] = (i + 1)
break
if cover is not None: print('Succesfully parsed')
print('Title:', parsed['title'])
print('Artist:', parsed['artist'])
print('Album:', parsed['album'])
print('Track:', parsed['track_no'], '/', parsed['tracks'])
print('Correct something?')
cover_url = str(cover.get('src')) if input('[y/N] ').lower == 'y':
if cover_url.startswith('/'): raise ParseError(ParseError.EDIT)
cover_url = BASEURL + cover_url
req = session.get(cover_url) print()
result['cover'] = req.content
result['cover_mime'] = req.headers.get(
'Content-Type', 'image/jpeg'
)
tracklist_elem = soup.select_one('.songlist-panel')
if tracklist_elem is not None:
tracklist = tracklist_elem.select(
'.listalbum-item'
)
result['tracks'] = len(tracklist)
current_url = re.search(
r'/(lyrics/.+?\.html)',
link,
)
result['track_no'] = 0
if current_url is not None:
for i, track in enumerate(tracklist):
track_url = track.select_one('a')
if track_url is None:
continue
track_href = str(track_url.get('href'))
if current_url[0] in track_href:
result['track_no'] = (i + 1)
break
return result
def manual_info_input() -> ParseResult: def manual_info_input(overwrite_lyrics: bool = True) -> None:
result = ParseResult( global parsed
title=input('Song title: '),
artist=input('Artist name: '), parsed['title'] = input('Song title: ', parsed['title'])
album=input('Album name: '), parsed['artist'] = input('Artist name: ', parsed['artist'])
year=input_num('Release year: '), parsed['album'] = input('Album name: ', parsed['album'])
track_no=input_num('Track #'), parsed['year'] = input_num('Release year: ', parsed['year'])
tracks=input_num('Tracks in album: '), parsed['track_no'] = input_num('Track #', parsed['track_no'])
lyrics='', cover=None, cover_mime=None, parsed['tracks'] = input_num('Tracks in album: ', parsed['tracks'])
)
editor = os.getenv('EDITOR', 'nano') editor = os.getenv('EDITOR', 'nano')
print('Now, paste the lyrics into a text editor') print('Now, paste the lyrics into a text editor')
@ -316,8 +344,10 @@ def manual_info_input() -> ParseResult:
try: try:
lyrics_file = Path('.') / 'lyrics.txt' lyrics_file = Path('.') / 'lyrics.txt'
with lyrics_file.open('wt') as f:
f.write('\n') if overwrite_lyrics or not lyrics_file.exists():
with lyrics_file.open('wt') as f:
f.write('\n')
subprocess.call([ subprocess.call([
editor, editor,
@ -326,14 +356,14 @@ def manual_info_input() -> ParseResult:
print('Reading file...') print('Reading file...')
with open('lyrics.txt', 'rt', encoding='utf-8') as f: with open('lyrics.txt', 'rt', encoding='utf-8') as f:
result['lyrics'] = f.read().strip() parsed['lyrics'] = f.read().strip()
print('Done') print('Done')
except OSError as err: except OSError as err:
logging.exception(err) logging.exception(err)
cover = input('Insert an album cover? [Y/n] ') cover = input('Insert an album cover? [Y/n] ')
if cover.lower() not in ('n','н'): if cover.lower() != 'n':
try: try:
print( print(
'Download the cover and enter its path:', 'Download the cover and enter its path:',
@ -343,26 +373,27 @@ def manual_info_input() -> ParseResult:
cover_file = Path(input().strip()) cover_file = Path(input().strip())
with cover_file.open('rb') as f: with cover_file.open('rb') as f:
result['cover'] = f.read() parsed['cover'] = f.read()
result['cover_mime'] = ( parsed['cover_mime'] = (
mimetypes.guess_type(cover_file)[0] mimetypes.guess_type(cover_file)[0]
or 'image/jpeg' or 'image/jpeg'
) )
except Exception as err: except Exception as err:
logging.exception(err) logging.exception(err)
return result print()
def tagmp3( def tagmp3(
file: str, file: str,
parsed: ParseResult,
copy: bool) -> None: copy: bool) -> None:
global parsed
oldpath = Path(file) oldpath = Path(file)
newpath = oldpath newpath = oldpath
if copy: if copy:
newdir = ( newdir = (
@ -387,7 +418,7 @@ def tagmp3(
cover = newdir / f'cover{ext}' cover = newdir / f'cover{ext}'
with cover.open('wb') as f: with cover.open('wb') as f:
f.write(parsed['cover']) f.write(parsed['cover'])
id3 = ID3(str(newpath)) id3 = ID3(str(newpath))
id3['TPE1'] = TPE1(text=parsed['artist']) id3['TPE1'] = TPE1(text=parsed['artist'])
id3['TIT2'] = TIT2(text=parsed['title']) id3['TIT2'] = TIT2(text=parsed['title'])

26
Makefile Normal file
View file

@ -0,0 +1,26 @@
clean:
rm -rf __pycache__/
rm -rf .mypy_cache/
rm -f lyrics.txt input
deps:
python3 -m pip install -r requirements.txt
run:
chmod +x ./autoytdlp.sh
./autoytdlp.sh
conv:
chmod +x ./convert.sh
./convert.sh
tags:
chmod +x ./id3tag.sh
./id3tag.sh
pyformat:
python3 -m autopep8 --in-place .*.py
pycheck:
python3 -m mypy .*.py
python3 -m pylint -j4 .*.py

View file

@ -1,6 +1,6 @@
#!/usr/bin/env bash #!/usr/bin/env bash
debug=1 debug=0
watching=1 watching=1
links=() links=()
success=0 success=0

View file

@ -20,3 +20,5 @@ echo
find "$directory" -type f -name "*.mp3" -exec \ find "$directory" -type f -name "*.mp3" -exec \
python3 ./.id3tag_helper.py "$copy_arg" {} \; python3 ./.id3tag_helper.py "$copy_arg" {} \;
rm -f ./input

191
pylintrc Normal file
View file

@ -0,0 +1,191 @@
[MAIN]
analyse-fallback-blocks=no
extension-pkg-allow-list=
extension-pkg-whitelist=
fail-on=
fail-under=10
ignore=CVS
ignore-paths=
ignore-patterns=^\.#
ignored-modules=
jobs=4
limit-inference-results=100
load-plugins=
persistent=yes
py-version=3.10
recursive=no
suggestion-mode=yes
unsafe-load-any-extension=no
[REPORTS]
evaluation=max(0, 0 if fatal else 10.0 - ((float(5 * error + warning + refactor + convention) / statement) * 10))
msg-template=
reports=no
score=yes
[MESSAGES CONTROL]
confidence=HIGH,
CONTROL_FLOW,
INFERENCE,
INFERENCE_FAILURE,
UNDEFINED
disable=raw-checker-failed,
bad-inline-option,
locally-disabled,
file-ignored,
suppressed-message,
useless-suppression,
deprecated-pragma,
use-symbolic-message-instead,
too-many-branches,
too-many-statements,
too-many-locals,
broad-except,
global-variable-not-assigned
enable=c-extension-no-member
[SIMILARITIES]
ignore-comments=yes
ignore-docstrings=yes
ignore-imports=yes
ignore-signatures=yes
min-similarity-lines=4
[MISCELLANEOUS]
notes=FIXME,
XXX,
TODO
notes-rgx=
[DESIGN]
exclude-too-few-public-methods=
ignored-parents=
max-args=5
max-attributes=7
max-bool-expr=5
max-branches=12
max-locals=15
max-parents=7
max-public-methods=20
max-returns=6
max-statements=50
min-public-methods=2
[STRING]
check-quote-consistency=no
check-str-concat-over-line-jumps=no
[CLASSES]
check-protected-access-in-special-methods=no
defining-attr-methods=__init__,
__new__,
setUp,
__post_init__
exclude-protected=_asdict,
_fields,
_replace,
_source,
_make
valid-classmethod-first-arg=cls
valid-metaclass-classmethod-first-arg=cls
[FORMAT]
expected-line-ending-format=
ignore-long-lines=^\s*(# )?<?https?://\S+>?$
indent-after-paren=4
indent-string=' '
max-line-length=100
max-module-lines=1000
single-line-class-stmt=no
single-line-if-stmt=no
[IMPORTS]
allow-any-import-level=
allow-wildcard-with-all=no
deprecated-modules=
ext-import-graph=
import-graph=
int-import-graph=
known-standard-library=
known-third-party=enchant
preferred-modules=
[VARIABLES]
additional-builtins=
allow-global-unused-variables=yes
allowed-redefined-builtins=
callbacks=cb_,
_cb
dummy-variables-rgx=_+$|(_[a-zA-Z0-9_]*[a-zA-Z0-9]+?$)|dummy|^ignored_|^unused_
ignored-argument-names=_.*|^ignored_|^unused_
init-import=no
redefining-builtins-modules=six.moves,past.builtins,future.builtins,builtins,io
[LOGGING]
logging-format-style=old
logging-modules=logging
[EXCEPTIONS]
overgeneral-exceptions=BaseException,
Exception
[BASIC]
argument-naming-style=snake_case
attr-naming-style=snake_case
bad-names=foo,
bar,
baz,
toto,
tutu,
tata
bad-names-rgxs=
class-attribute-naming-style=any
class-const-naming-style=UPPER_CASE
class-naming-style=PascalCase
const-naming-style=any
docstring-min-length=-1
function-naming-style=snake_case
good-names=i,
j,
k,
f,
ex,
Run,
_
good-names-rgxs=
include-naming-hint=no
inlinevar-naming-style=any
method-naming-style=snake_case
module-naming-style=snake_case
name-group=
no-docstring-rgx=^_
property-classes=abc.abstractproperty
variable-naming-style=snake_case
[SPELLING]
max-spelling-suggestions=4
spelling-dict=
spelling-ignore-comment-directives=fmt: on,fmt: off,noqa:,noqa,nosec,isort:skip,mypy:
spelling-ignore-words=
spelling-private-dict-file=
spelling-store-unknown-words=no
[TYPECHECK]
contextmanager-decorators=contextlib.contextmanager
generated-members=
ignore-none=yes
ignore-on-opaque-inference=yes
ignored-checks-for-mixins=no-member,
not-async-context-manager,
not-context-manager,
attribute-defined-outside-init
ignored-classes=optparse.Values,thread._local,_thread._local,argparse.Namespace
missing-member-hint=yes
missing-member-hint-distance=1
missing-member-max-choices=1
mixin-class-rgx=.*[Mm]ixin
signature-mutators=
[REFACTORING]
max-nested-blocks=5
never-returning-functions=sys.exit,argparse.parse_error