Compare commits

...

3 commits

Author SHA1 Message Date
DarkCat09 3e13001835
CSS: fix <pre> wrap, add dark theme, colored log 2024-05-28 13:52:16 +04:00
DarkCat09 312a688c89
Frontend: proxy checkbox 2024-05-28 13:27:53 +04:00
DarkCat09 9071017dbf
Refactor: unify YDL object and proxy cfg field
This would allow to add sites much simplier
or even drop the ydl_fn_keys.
Main purpose of the refactoring is a code cleanup.
2024-05-28 13:21:23 +04:00
8 changed files with 113 additions and 117 deletions

View file

@ -20,11 +20,8 @@ class Config:
os.getenv('TRACK_FILE_TMPL') or '%(track)S.%(ext)s', os.getenv('TRACK_FILE_TMPL') or '%(track)S.%(ext)s',
) )
# Proxy URL for yt_proxied downloader (can be used for geo-restricted content) # `or None` -- defaults to None also if PROXY is an empty string
self.yt_proxy = os.getenv('YT_PROXY') or None self.proxy = os.getenv('PROXY') or None
# Proxy for soundcloud
self.sc_proxy = os.getenv('SC_PROXY') or None
self.save_lyrics = _parse_bool(os.getenv('SAVE_LYRICS'), True) self.save_lyrics = _parse_bool(os.getenv('SAVE_LYRICS'), True)
self.save_cover = _parse_bool(os.getenv('SAVE_COVER'), True) self.save_cover = _parse_bool(os.getenv('SAVE_COVER'), True)

View file

@ -10,19 +10,24 @@ import genius
ENC_UTF8 = 3 ENC_UTF8 = 3
class InfoYouTubePP(PostProcessor): class InfoGenPP(PostProcessor):
'''Generates YT Music fields in info if necessary. '''Generates track, artist(s), album, track_number fields
from other info fields if they are not present in current info dict.
Must be run before downloading and post-processing with Must be run before downloading and post-processing with
FFmpegExtractAudioPP and ID3YouTubePP, so use only with FFmpegExtractAudioPP and ID3TagsPP, so use only with
when<="before_dl" ("pre_process" also suits, see yt_dlp.utils.POSTPROCESS_WHEN) when="pre_process" for correct file path and ID3 tags'''
for correct file path and ID3 tags'''
def run(self, information): def run(self, information):
if not 'track' in information: if not 'track' in information:
information['track'] = information['title'] information['track'] = information['title']
if not 'artist' in information: if not 'artist' in information:
information['artist'] = information['uploader'].removesuffix(' - Topic') artist = information['uploader'].removesuffix(' - Topic')
# <Really-dumb-fix>
if artist == 'LINKIN PARK':
artist = 'Linkin Park'
# </Really-dumb-fix>
information['artist'] = artist
if not 'artists' in information: if not 'artists' in information:
information['artists'] = [information['artist']] information['artists'] = [information['artist']]
@ -44,8 +49,8 @@ class InfoYouTubePP(PostProcessor):
class ID3TagsPP(PostProcessor): class ID3TagsPP(PostProcessor):
'''Inserts ID3 tags after all PPs (for YT: InfoYouTubePP and FFmpegExtractAudioPP), '''Inserts ID3 tags including lyrics (see parser in backend/genius.py).
triggers searching and parsing lyrics from Genius''' Must be run after all PPs ()'''
def __init__(self) -> None: def __init__(self) -> None:
self.cfg = config.get() self.cfg = config.get()

View file

@ -30,21 +30,19 @@ async def handler(socket: SocketT) -> None:
try: try:
data = json.loads(message) data = json.loads(message)
print(data)
match data['action']: match data['action']:
case 'list': # list tracks in album case 'list': # list tracks in album
ydls.choose_ydl(data['site']) ydls.update_params(data['site'], data.get('proxy', False))
await socket.send(response.playlist( await socket.send(response.playlist(
await ydls.get_playlist_items(data['url']), await ydls.get_playlist_items(data['url']),
)) ))
case 'download': # download by URL case 'download': # download by URL
ydls.choose_ydl(data['site']) ydls.update_params(data['site'], data.get('proxy', False), data.get('items'))
ret = await ydls.download( ret = await ydls.download(data['url'])
data['url'],
data.get('items'),
)
await socket.send(response.ydl_end(ret)) await socket.send(response.ydl_end(ret))
case _: case _:
@ -57,6 +55,8 @@ async def handler(socket: SocketT) -> None:
if not socket.closed: if not socket.closed:
await socket.send(response.error(ex)) await socket.send(response.error(ex))
ydls.reset_params()
ydls.cleanup() ydls.cleanup()

View file

@ -15,7 +15,7 @@ TEST_MP3 = 'music/test.mp3'
INFO_BEFORE = { INFO_BEFORE = {
"filepath": TEST_MP3, "filepath": TEST_MP3,
"title": "A Line in the Sand", "title": "A Line in the Sand",
"channel": "Linkin Park - Topic", "uploader": "Linkin Park - Topic",
"playlist": "Album \u2013 The Hunting Party", "playlist": "Album \u2013 The Hunting Party",
"playlist_index": 12, "playlist_index": 12,
"release_year": 2015, "release_year": 2015,
@ -44,7 +44,7 @@ class TestPostProcessorsOnFakeData(TestCase):
def test_infoytpp(self) -> None: def test_infoytpp(self) -> None:
_, info = id3pp.InfoYouTubePP().run(INFO_BEFORE) _, info = id3pp.InfoGenPP().run(INFO_BEFORE)
self.assertDictEqual(info, INFO_AFTER) self.assertDictEqual(info, INFO_AFTER)
def test_id3(self) -> None: def test_id3(self) -> None:

View file

@ -10,46 +10,7 @@ import response
import id3pp import id3pp
class _CreateYDL: ydl_fn_keys = {'youtube', 'yandex', 'soundcloud'}
@staticmethod
def youtube() -> YoutubeDL:
ydl = YoutubeDL({'format': 'ba'})
ydl.add_post_processor(id3pp.InfoYouTubePP(), when='before_dl')
ydl.add_post_processor(FFmpegExtractAudioPP(preferredcodec='mp3'), when='post_process')
return ydl
@staticmethod
def yt_proxied() -> YoutubeDL:
ydl = _CreateYDL.youtube()
proxy = config.get().yt_proxy
if proxy is not None:
ydl.params['proxy'] = proxy
return ydl
@staticmethod
def soundcloud() -> YoutubeDL:
ydl = YoutubeDL({'format': 'ba[ext=mp3]/ba'})
ydl.add_post_processor(id3pp.InfoYouTubePP(), when='pre_process')
proxy = config.get().sc_proxy
if proxy is not None:
ydl.params['proxy'] = proxy
ydl.add_post_processor(FFmpegExtractAudioPP(preferredcodec='mp3'), when='post_process')
return ydl
@staticmethod
def yandex() -> YoutubeDL:
return YoutubeDL()
create_ydl_fn = {
'youtube': _CreateYDL.youtube,
'yt_proxied': _CreateYDL.yt_proxied,
'soundcloud': _CreateYDL.soundcloud,
'yandex': _CreateYDL.yandex,
}
ydl_fn_keys = create_ydl_fn.keys()
# need process=True for track title in extract_info output # need process=True for track title in extract_info output
NP_YDLS = {'yandex', 'soundcloud'} NP_YDLS = {'yandex', 'soundcloud'}
@ -83,98 +44,90 @@ class Downloader:
def __init__(self, logger: YdlLogger | None = None) -> None: def __init__(self, logger: YdlLogger | None = None) -> None:
self.ydls: dict[str, YoutubeDL | None] = { self.cfg = config.get()
key: None
for key in ydl_fn_keys
}
self.cur_ydl: YoutubeDL | None = None self.ydl = YoutubeDL({'format': 'ba[ext=mp3]/ba/b'})
self.cur_site = '' self.ydl.add_post_processor(id3pp.InfoGenPP(), when='pre_process')
# Note: it skips converting if downloaded file is already MP3
self.ydl.add_post_processor(FFmpegExtractAudioPP(preferredcodec='mp3'), when='post_process')
# ID3 tags are set after all pre/post-processings
self.ydl.add_post_processor(id3pp.ID3TagsPP(), when='post_process')
self.id3pp_obj = id3pp.ID3TagsPP() if logger is not None:
self.ydl.params['logger'] = logger
self.logger = logger self.ydl.params['outtmpl']['default'] = self.cfg.tmpl
def choose_ydl(self, site: str) -> None: self.need_process = False
self.updated_params = []
ydl = self.ydls[site] def update_params(
cfg = config.get() self,
site: str,
proxy: bool = False,
items: Iterable[int] | None = None) -> None:
if ydl is None: self.need_process = site in NP_YDLS
ydl = create_ydl_fn[site]()
if self.logger is not None: cookies = self.cfg.cookies_dir / (site + '.txt')
ydl.params['logger'] = self.logger if cookies.exists():
self.ydl.params['cookiefile'] = str(cookies)
self.updated_params.append('cookiefile')
ydl.params['outtmpl']['default'] = cfg.tmpl if proxy and self.cfg.proxy is not None:
ydl.add_post_processor(self.id3pp_obj, when='post_process') self.ydl.params['proxy'] = self.cfg.proxy
self.updated_params.append('proxy')
cookies = cfg.cookies_dir / (site + '.txt') if items:
if cookies.exists(): self.ydl.params['playlist_items'] = (
ydl.params['cookiefile'] = str(cookies) ','.join(str(i) for i in items)
)
self.updated_params.append('playlist_items')
self.cur_ydl = ydl def reset_params(self) -> None:
self.cur_site = site
def get_cur_ydl(self) -> YoutubeDL: for param in self.updated_params:
del self.ydl.params[param]
ydl = self.cur_ydl self.updated_params.clear()
if ydl is None: self.need_process = False
raise RuntimeError('ydl object not initialized')
return ydl
async def get_playlist_items(self, url: str) -> list[str]: async def get_playlist_items(self, url: str) -> list[str]:
return await asyncio.get_event_loop().run_in_executor( return await asyncio.get_event_loop().run_in_executor(
None, None,
Downloader._target_get_playlist_items, Downloader._target_get_playlist_items,
self.get_cur_ydl(), self.ydl,
url, url,
self.cur_site in NP_YDLS, self.need_process,
) )
@staticmethod @staticmethod
def _target_get_playlist_items(ydl: YoutubeDL, url: str, process: bool) -> list[str]: def _target_get_playlist_items(ydl: YoutubeDL, url: str, process: bool) -> list[str]:
info = ydl.extract_info(url, download=False, process=process) info = ydl.extract_info(url, download=False, process=process)
if info is None: if info is None:
raise RuntimeError('ydl.extract_info returned None') raise RuntimeError('ydl.extract_info returned None')
return [ return [
entry['track'] if 'track' in entry else entry['title'] entry['track'] if 'track' in entry else entry['title']
for entry in info['entries'] for entry in info['entries']
] ]
async def download( async def download(self, url: str) -> int:
self,
url: str,
playlist_items: Iterable[int] | None = None) -> int:
return await asyncio.get_event_loop().run_in_executor( return await asyncio.get_event_loop().run_in_executor(
None, None,
Downloader._target_download, Downloader._target_download,
self.get_cur_ydl(), self.ydl,
url, url,
playlist_items,
) )
@staticmethod @staticmethod
def _target_download( def _target_download(ydl: YoutubeDL, url: str) -> int:
ydl: YoutubeDL,
url: str,
playlist_items: Iterable[int] | None = None) -> int:
if playlist_items: return ydl.download(url)
ydl.params['playlist_items'] = ','.join(str(i) for i in playlist_items)
ret = ydl.download(url)
if playlist_items:
del ydl.params['playlist_items']
return ret
def cleanup(self) -> None: def cleanup(self) -> None:
for ydl in self.ydls.values(): self.ydl.close()
if ydl is not None:
ydl.close()

View file

@ -15,11 +15,13 @@
<div> <div>
<select id="site-select"> <select id="site-select">
<option value="youtube" selected>YouTube</option> <option value="youtube" selected>YouTube</option>
<option value="yt_proxied">YT proxied</option>
<option value="soundcloud">SoundCloud</option> <option value="soundcloud">SoundCloud</option>
<option value="yandex">Yandex Music</option> <option value="yandex">Yandex Music</option>
</select> </select>
</div> </div>
<div>
<label for="proxy-cb"><input type="checkbox" id="proxy-cb">Proxy</label>
</div>
<div> <div>
<button type="button" id="items-btn">Get playlist items</button> <button type="button" id="items-btn">Get playlist items</button>
</div> </div>

View file

@ -3,14 +3,15 @@ addEventListener('DOMContentLoaded', () => {
const urlField = document.getElementById('url') const urlField = document.getElementById('url')
/** @type{HTMLSelectElement} */ /** @type{HTMLSelectElement} */
const site = document.getElementById('site-select') const site = document.getElementById('site-select')
/** @type{HTMLInputElement} */
const proxyFlag = document.getElementById('proxy-cb')
document.getElementById('guess-site-btn').addEventListener('click', () => { document.getElementById('guess-site-btn').addEventListener('click', () => {
const url = urlField.value const url = urlField.value
if (url.includes('/watch?v=') || url.includes('/playlist?list=')) { if (url.includes('/watch?v=') || url.includes('/playlist?list=')) {
if (site.value == 'yt_proxied') {
return
}
site.value = 'youtube' site.value = 'youtube'
} else if (url.includes('soundcloud.com/')) {
site.value = 'soundcloud'
} else if (url.includes('://music.yandex.')) { } else if (url.includes('://music.yandex.')) {
site.value = 'yandex' site.value = 'yandex'
} }
@ -28,6 +29,7 @@ addEventListener('DOMContentLoaded', () => {
action: 'list', action: 'list',
site: site.value, site: site.value,
url: urlField.value, url: urlField.value,
proxy: proxyFlag.checked,
})) }))
logField.textContent = '' // clear logField.textContent = '' // clear
}) })
@ -37,6 +39,7 @@ addEventListener('DOMContentLoaded', () => {
action: 'download', action: 'download',
site: site.value, site: site.value,
url: urlField.value, url: urlField.value,
proxy: proxyFlag.checked,
items: items, items: items,
})) }))
items = [] items = []

View file

@ -8,6 +8,9 @@ body {
row-gap: 0.375rem; row-gap: 0.375rem;
font-family: 'Noto Sans', 'Roboto', 'Ubuntu', sans-serif; font-family: 'Noto Sans', 'Roboto', 'Ubuntu', sans-serif;
background: #111118;
color: #eee;
} }
#items-container { #items-container {
@ -18,3 +21,36 @@ body {
margin: 0.25rem 0; margin: 0.25rem 0;
} }
pre {
max-width: 50rem;
white-space: pre-wrap;
word-wrap: break-word;
}
.log-warning {
color: #d79f21;
}
.log-error {
color: #d9214a;
}
input, select, button {
background: #111118;
color: #eee;
outline: none;
border: none;
border-bottom: 1px solid #eee;
font-size: 1rem;
}
input:hover, select:hover, button:hover {
background: #444451;
}
button {
border: 1px solid #eee;
border-radius: 0.25rem;
cursor: pointer;
}