Compare commits

..

No commits in common. "3e130018355daaebbf9f331c091b7f929b53472d" and "7a75805423f11023711e4cec636f16732f3039d4" have entirely different histories.

8 changed files with 117 additions and 113 deletions

View file

@ -20,8 +20,11 @@ class Config:
os.getenv('TRACK_FILE_TMPL') or '%(track)S.%(ext)s', os.getenv('TRACK_FILE_TMPL') or '%(track)S.%(ext)s',
) )
# `or None` -- defaults to None also if PROXY is an empty string # Proxy URL for yt_proxied downloader (can be used for geo-restricted content)
self.proxy = os.getenv('PROXY') or None self.yt_proxy = os.getenv('YT_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,24 +10,19 @@ import genius
ENC_UTF8 = 3 ENC_UTF8 = 3
class InfoGenPP(PostProcessor): class InfoYouTubePP(PostProcessor):
'''Generates track, artist(s), album, track_number fields '''Generates YT Music fields in info if necessary.
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 ID3TagsPP, so use only with FFmpegExtractAudioPP and ID3YouTubePP, so use only with
when="pre_process" for correct file path and ID3 tags''' when<="before_dl" ("pre_process" also suits, see yt_dlp.utils.POSTPROCESS_WHEN)
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:
artist = information['uploader'].removesuffix(' - Topic') information['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']]
@ -49,8 +44,8 @@ class InfoGenPP(PostProcessor):
class ID3TagsPP(PostProcessor): class ID3TagsPP(PostProcessor):
'''Inserts ID3 tags including lyrics (see parser in backend/genius.py). '''Inserts ID3 tags after all PPs (for YT: InfoYouTubePP and FFmpegExtractAudioPP),
Must be run after all PPs ()''' triggers searching and parsing lyrics from Genius'''
def __init__(self) -> None: def __init__(self) -> None:
self.cfg = config.get() self.cfg = config.get()

View file

@ -30,19 +30,21 @@ 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.update_params(data['site'], data.get('proxy', False)) ydls.choose_ydl(data['site'])
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.update_params(data['site'], data.get('proxy', False), data.get('items')) ydls.choose_ydl(data['site'])
ret = await ydls.download(data['url']) ret = await ydls.download(
data['url'],
data.get('items'),
)
await socket.send(response.ydl_end(ret)) await socket.send(response.ydl_end(ret))
case _: case _:
@ -55,8 +57,6 @@ 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",
"uploader": "Linkin Park - Topic", "channel": "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.InfoGenPP().run(INFO_BEFORE) _, info = id3pp.InfoYouTubePP().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,7 +10,46 @@ import response
import id3pp import id3pp
ydl_fn_keys = {'youtube', 'yandex', 'soundcloud'} class _CreateYDL:
@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'}
@ -44,90 +83,98 @@ class Downloader:
def __init__(self, logger: YdlLogger | None = None) -> None: def __init__(self, logger: YdlLogger | None = None) -> None:
self.cfg = config.get() self.ydls: dict[str, YoutubeDL | None] = {
key: None
for key in ydl_fn_keys
}
self.ydl = YoutubeDL({'format': 'ba[ext=mp3]/ba/b'}) self.cur_ydl: YoutubeDL | None = None
self.ydl.add_post_processor(id3pp.InfoGenPP(), when='pre_process') self.cur_site = ''
# 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')
if logger is not None: self.id3pp_obj = id3pp.ID3TagsPP()
self.ydl.params['logger'] = logger
self.ydl.params['outtmpl']['default'] = self.cfg.tmpl self.logger = logger
self.need_process = False def choose_ydl(self, site: str) -> None:
self.updated_params = []
def update_params( ydl = self.ydls[site]
self, cfg = config.get()
site: str,
proxy: bool = False,
items: Iterable[int] | None = None) -> None:
self.need_process = site in NP_YDLS if ydl is None:
ydl = create_ydl_fn[site]()
cookies = self.cfg.cookies_dir / (site + '.txt') if self.logger is not None:
if cookies.exists(): ydl.params['logger'] = self.logger
self.ydl.params['cookiefile'] = str(cookies)
self.updated_params.append('cookiefile')
if proxy and self.cfg.proxy is not None: ydl.params['outtmpl']['default'] = cfg.tmpl
self.ydl.params['proxy'] = self.cfg.proxy ydl.add_post_processor(self.id3pp_obj, when='post_process')
self.updated_params.append('proxy')
if items: cookies = cfg.cookies_dir / (site + '.txt')
self.ydl.params['playlist_items'] = ( if cookies.exists():
','.join(str(i) for i in items) ydl.params['cookiefile'] = str(cookies)
)
self.updated_params.append('playlist_items')
def reset_params(self) -> None: self.cur_ydl = ydl
self.cur_site = site
for param in self.updated_params: def get_cur_ydl(self) -> YoutubeDL:
del self.ydl.params[param]
self.updated_params.clear() ydl = self.cur_ydl
self.need_process = False if ydl is None:
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.ydl, self.get_cur_ydl(),
url, url,
self.need_process, self.cur_site in NP_YDLS,
) )
@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(self, url: str) -> int: async def download(
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.ydl, self.get_cur_ydl(),
url, url,
playlist_items,
) )
@staticmethod @staticmethod
def _target_download(ydl: YoutubeDL, url: str) -> int: def _target_download(
ydl: YoutubeDL,
url: str,
playlist_items: Iterable[int] | None = None) -> int:
return ydl.download(url) if playlist_items:
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:
self.ydl.close() for ydl in self.ydls.values():
if ydl is not None:
ydl.close()

View file

@ -15,13 +15,11 @@
<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,15 +3,14 @@ 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'
} }
@ -29,7 +28,6 @@ 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
}) })
@ -39,7 +37,6 @@ 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,9 +8,6 @@ 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 {
@ -21,36 +18,3 @@ 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;
}