mirror of
https://zotify.xyz/zotify/zotify.git
synced 2024-11-10 01:02:06 +01:00
changes
This commit is contained in:
parent
a10b32b5b7
commit
360e342bc2
18 changed files with 923 additions and 463 deletions
11
CHANGELOG.md
11
CHANGELOG.md
|
@ -2,8 +2,6 @@
|
|||
|
||||
## v1.0.0
|
||||
|
||||
An unexpected reboot.
|
||||
|
||||
### BREAKING CHANGES AHEAD
|
||||
|
||||
- Most components have been completely rewritten to address some fundamental design issues with the previous codebase, This update will provide a better base for new features in the future.
|
||||
|
@ -12,7 +10,7 @@ An unexpected reboot.
|
|||
|
||||
### Changes
|
||||
|
||||
- Genre metadata available for tracks downloaded from an album
|
||||
- Genre metadata available for all tracks
|
||||
- Boolean command line options are now set like `--save-metadata` or `--no-save-metadata` for True or False
|
||||
- Setting `--config` (formerly `--config-location`) can be set to "None" to not use any config file
|
||||
- Search result selector now accepts both comma-seperated and hyphen-seperated values at the same time
|
||||
|
@ -24,10 +22,12 @@ An unexpected reboot.
|
|||
- The output template used is now based on track info rather than search result category
|
||||
- Search queries with spaces no longer need to be in quotes
|
||||
- File metadata no longer uses sanitized file metadata, this will result in more accurate metadata.
|
||||
- Replaced ffmpy with custom implementation
|
||||
- Replaced ffmpy with custom implementation providing more tags
|
||||
- Fixed artist download missing some tracks
|
||||
|
||||
### Additions
|
||||
|
||||
- New library location for playlists `playlist_library`
|
||||
- Added new command line arguments
|
||||
- `--library`/`-l` overrides both `music_library` and `podcast_library` options similar to `--output`/`-o`
|
||||
- `--category`/`-c` will limit search results to a certain type, accepted values are "album", "artist", "playlist", "track", "show", "episode". Accepts multiple choices.
|
||||
|
@ -52,13 +52,13 @@ An unexpected reboot.
|
|||
- `{album_artist}`
|
||||
- `{album_artists}`
|
||||
- `{duration}` (milliseconds)
|
||||
- `{explicit}`
|
||||
- `{isrc}`
|
||||
- `{licensor}`
|
||||
- `{popularity}`
|
||||
- `{release_date}`
|
||||
- `{track_number}`
|
||||
- Genre information is now more accurate and is always enabled
|
||||
- New library location for playlists `playlist_library`
|
||||
- Added download option for "liked episodes" `--liked-episodes`/`-le`
|
||||
- Added `save_metadata` option to fully disable writing track metadata
|
||||
- Added support for ReplayGain
|
||||
|
@ -79,6 +79,7 @@ An unexpected reboot.
|
|||
- Removed `print_api_errors` because API errors are now treated like regular errors
|
||||
- Removed the following config options due to their corresponding features being removed:
|
||||
- `bulk_wait_time`
|
||||
- `chunk_size`
|
||||
- `download_real_time`
|
||||
- `md_allgenres`
|
||||
- `md_genredelimiter`
|
||||
|
|
2
LICENCE
2
LICENCE
|
@ -1,4 +1,4 @@
|
|||
Copyright (c) 2022 Zotify Contributors
|
||||
Copyright (c) 2024 Zotify Contributors
|
||||
|
||||
This software is provided 'as-is', without any express or implied
|
||||
warranty. In no event will the authors be held liable for any damages
|
||||
|
|
18
Pipfile
Normal file
18
Pipfile
Normal file
|
@ -0,0 +1,18 @@
|
|||
[[source]]
|
||||
url = "https://pypi.org/simple"
|
||||
verify_ssl = true
|
||||
name = "pypi"
|
||||
|
||||
[packages]
|
||||
librespot = {git = "git+https://github.com/kokarare1212/librespot-python"}
|
||||
music-tag = {git = "git+https://zotify.xyz/zotify/music-tag"}
|
||||
mutagen = "*"
|
||||
pillow = "*"
|
||||
pwinput = "*"
|
||||
requests = "*"
|
||||
tqdm = "*"
|
||||
|
||||
[dev-packages]
|
||||
|
||||
[requires]
|
||||
python_version = "3.11"
|
414
Pipfile.lock
generated
Normal file
414
Pipfile.lock
generated
Normal file
|
@ -0,0 +1,414 @@
|
|||
{
|
||||
"_meta": {
|
||||
"hash": {
|
||||
"sha256": "dfbc5e27f802eeeddf2967a8d8d280346f8e3b4e4759b4bea10f59dbee08a0ee"
|
||||
},
|
||||
"pipfile-spec": 6,
|
||||
"requires": {
|
||||
"python_version": "3.11"
|
||||
},
|
||||
"sources": [
|
||||
{
|
||||
"name": "pypi",
|
||||
"url": "https://pypi.org/simple",
|
||||
"verify_ssl": true
|
||||
}
|
||||
]
|
||||
},
|
||||
"default": {
|
||||
"certifi": {
|
||||
"hashes": [
|
||||
"sha256:0569859f95fc761b18b45ef421b1290a0f65f147e92a1e5eb3e635f9a5e4e66f",
|
||||
"sha256:dc383c07b76109f368f6106eee2b593b04a011ea4d55f652c6ca24a754d1cdd1"
|
||||
],
|
||||
"markers": "python_version >= '3.6'",
|
||||
"version": "==2024.2.2"
|
||||
},
|
||||
"charset-normalizer": {
|
||||
"hashes": [
|
||||
"sha256:06435b539f889b1f6f4ac1758871aae42dc3a8c0e24ac9e60c2384973ad73027",
|
||||
"sha256:06a81e93cd441c56a9b65d8e1d043daeb97a3d0856d177d5c90ba85acb3db087",
|
||||
"sha256:0a55554a2fa0d408816b3b5cedf0045f4b8e1a6065aec45849de2d6f3f8e9786",
|
||||
"sha256:0b2b64d2bb6d3fb9112bafa732def486049e63de9618b5843bcdd081d8144cd8",
|
||||
"sha256:10955842570876604d404661fbccbc9c7e684caf432c09c715ec38fbae45ae09",
|
||||
"sha256:122c7fa62b130ed55f8f285bfd56d5f4b4a5b503609d181f9ad85e55c89f4185",
|
||||
"sha256:1ceae2f17a9c33cb48e3263960dc5fc8005351ee19db217e9b1bb15d28c02574",
|
||||
"sha256:1d3193f4a680c64b4b6a9115943538edb896edc190f0b222e73761716519268e",
|
||||
"sha256:1f79682fbe303db92bc2b1136016a38a42e835d932bab5b3b1bfcfbf0640e519",
|
||||
"sha256:2127566c664442652f024c837091890cb1942c30937add288223dc895793f898",
|
||||
"sha256:22afcb9f253dac0696b5a4be4a1c0f8762f8239e21b99680099abd9b2b1b2269",
|
||||
"sha256:25baf083bf6f6b341f4121c2f3c548875ee6f5339300e08be3f2b2ba1721cdd3",
|
||||
"sha256:2e81c7b9c8979ce92ed306c249d46894776a909505d8f5a4ba55b14206e3222f",
|
||||
"sha256:3287761bc4ee9e33561a7e058c72ac0938c4f57fe49a09eae428fd88aafe7bb6",
|
||||
"sha256:34d1c8da1e78d2e001f363791c98a272bb734000fcef47a491c1e3b0505657a8",
|
||||
"sha256:37e55c8e51c236f95b033f6fb391d7d7970ba5fe7ff453dad675e88cf303377a",
|
||||
"sha256:3d47fa203a7bd9c5b6cee4736ee84ca03b8ef23193c0d1ca99b5089f72645c73",
|
||||
"sha256:3e4d1f6587322d2788836a99c69062fbb091331ec940e02d12d179c1d53e25fc",
|
||||
"sha256:42cb296636fcc8b0644486d15c12376cb9fa75443e00fb25de0b8602e64c1714",
|
||||
"sha256:45485e01ff4d3630ec0d9617310448a8702f70e9c01906b0d0118bdf9d124cf2",
|
||||
"sha256:4a78b2b446bd7c934f5dcedc588903fb2f5eec172f3d29e52a9096a43722adfc",
|
||||
"sha256:4ab2fe47fae9e0f9dee8c04187ce5d09f48eabe611be8259444906793ab7cbce",
|
||||
"sha256:4d0d1650369165a14e14e1e47b372cfcb31d6ab44e6e33cb2d4e57265290044d",
|
||||
"sha256:549a3a73da901d5bc3ce8d24e0600d1fa85524c10287f6004fbab87672bf3e1e",
|
||||
"sha256:55086ee1064215781fff39a1af09518bc9255b50d6333f2e4c74ca09fac6a8f6",
|
||||
"sha256:572c3763a264ba47b3cf708a44ce965d98555f618ca42c926a9c1616d8f34269",
|
||||
"sha256:573f6eac48f4769d667c4442081b1794f52919e7edada77495aaed9236d13a96",
|
||||
"sha256:5b4c145409bef602a690e7cfad0a15a55c13320ff7a3ad7ca59c13bb8ba4d45d",
|
||||
"sha256:6463effa3186ea09411d50efc7d85360b38d5f09b870c48e4600f63af490e56a",
|
||||
"sha256:65f6f63034100ead094b8744b3b97965785388f308a64cf8d7c34f2f2e5be0c4",
|
||||
"sha256:663946639d296df6a2bb2aa51b60a2454ca1cb29835324c640dafb5ff2131a77",
|
||||
"sha256:6897af51655e3691ff853668779c7bad41579facacf5fd7253b0133308cf000d",
|
||||
"sha256:68d1f8a9e9e37c1223b656399be5d6b448dea850bed7d0f87a8311f1ff3dabb0",
|
||||
"sha256:6ac7ffc7ad6d040517be39eb591cac5ff87416c2537df6ba3cba3bae290c0fed",
|
||||
"sha256:6b3251890fff30ee142c44144871185dbe13b11bab478a88887a639655be1068",
|
||||
"sha256:6c4caeef8fa63d06bd437cd4bdcf3ffefe6738fb1b25951440d80dc7df8c03ac",
|
||||
"sha256:6ef1d82a3af9d3eecdba2321dc1b3c238245d890843e040e41e470ffa64c3e25",
|
||||
"sha256:753f10e867343b4511128c6ed8c82f7bec3bd026875576dfd88483c5c73b2fd8",
|
||||
"sha256:7cd13a2e3ddeed6913a65e66e94b51d80a041145a026c27e6bb76c31a853c6ab",
|
||||
"sha256:7ed9e526742851e8d5cc9e6cf41427dfc6068d4f5a3bb03659444b4cabf6bc26",
|
||||
"sha256:7f04c839ed0b6b98b1a7501a002144b76c18fb1c1850c8b98d458ac269e26ed2",
|
||||
"sha256:802fe99cca7457642125a8a88a084cef28ff0cf9407060f7b93dca5aa25480db",
|
||||
"sha256:80402cd6ee291dcb72644d6eac93785fe2c8b9cb30893c1af5b8fdd753b9d40f",
|
||||
"sha256:8465322196c8b4d7ab6d1e049e4c5cb460d0394da4a27d23cc242fbf0034b6b5",
|
||||
"sha256:86216b5cee4b06df986d214f664305142d9c76df9b6512be2738aa72a2048f99",
|
||||
"sha256:87d1351268731db79e0f8e745d92493ee2841c974128ef629dc518b937d9194c",
|
||||
"sha256:8bdb58ff7ba23002a4c5808d608e4e6c687175724f54a5dade5fa8c67b604e4d",
|
||||
"sha256:8c622a5fe39a48f78944a87d4fb8a53ee07344641b0562c540d840748571b811",
|
||||
"sha256:8d756e44e94489e49571086ef83b2bb8ce311e730092d2c34ca8f7d925cb20aa",
|
||||
"sha256:8f4a014bc36d3c57402e2977dada34f9c12300af536839dc38c0beab8878f38a",
|
||||
"sha256:9063e24fdb1e498ab71cb7419e24622516c4a04476b17a2dab57e8baa30d6e03",
|
||||
"sha256:90d558489962fd4918143277a773316e56c72da56ec7aa3dc3dbbe20fdfed15b",
|
||||
"sha256:923c0c831b7cfcb071580d3f46c4baf50f174be571576556269530f4bbd79d04",
|
||||
"sha256:95f2a5796329323b8f0512e09dbb7a1860c46a39da62ecb2324f116fa8fdc85c",
|
||||
"sha256:96b02a3dc4381e5494fad39be677abcb5e6634bf7b4fa83a6dd3112607547001",
|
||||
"sha256:9f96df6923e21816da7e0ad3fd47dd8f94b2a5ce594e00677c0013018b813458",
|
||||
"sha256:a10af20b82360ab00827f916a6058451b723b4e65030c5a18577c8b2de5b3389",
|
||||
"sha256:a50aebfa173e157099939b17f18600f72f84eed3049e743b68ad15bd69b6bf99",
|
||||
"sha256:a981a536974bbc7a512cf44ed14938cf01030a99e9b3a06dd59578882f06f985",
|
||||
"sha256:a9a8e9031d613fd2009c182b69c7b2c1ef8239a0efb1df3f7c8da66d5dd3d537",
|
||||
"sha256:ae5f4161f18c61806f411a13b0310bea87f987c7d2ecdbdaad0e94eb2e404238",
|
||||
"sha256:aed38f6e4fb3f5d6bf81bfa990a07806be9d83cf7bacef998ab1a9bd660a581f",
|
||||
"sha256:b01b88d45a6fcb69667cd6d2f7a9aeb4bf53760d7fc536bf679ec94fe9f3ff3d",
|
||||
"sha256:b261ccdec7821281dade748d088bb6e9b69e6d15b30652b74cbbac25e280b796",
|
||||
"sha256:b2b0a0c0517616b6869869f8c581d4eb2dd83a4d79e0ebcb7d373ef9956aeb0a",
|
||||
"sha256:b4a23f61ce87adf89be746c8a8974fe1c823c891d8f86eb218bb957c924bb143",
|
||||
"sha256:bd8f7df7d12c2db9fab40bdd87a7c09b1530128315d047a086fa3ae3435cb3a8",
|
||||
"sha256:beb58fe5cdb101e3a055192ac291b7a21e3b7ef4f67fa1d74e331a7f2124341c",
|
||||
"sha256:c002b4ffc0be611f0d9da932eb0f704fe2602a9a949d1f738e4c34c75b0863d5",
|
||||
"sha256:c083af607d2515612056a31f0a8d9e0fcb5876b7bfc0abad3ecd275bc4ebc2d5",
|
||||
"sha256:c180f51afb394e165eafe4ac2936a14bee3eb10debc9d9e4db8958fe36afe711",
|
||||
"sha256:c235ebd9baae02f1b77bcea61bce332cb4331dc3617d254df3323aa01ab47bd4",
|
||||
"sha256:cd70574b12bb8a4d2aaa0094515df2463cb429d8536cfb6c7ce983246983e5a6",
|
||||
"sha256:d0eccceffcb53201b5bfebb52600a5fb483a20b61da9dbc885f8b103cbe7598c",
|
||||
"sha256:d965bba47ddeec8cd560687584e88cf699fd28f192ceb452d1d7ee807c5597b7",
|
||||
"sha256:db364eca23f876da6f9e16c9da0df51aa4f104a972735574842618b8c6d999d4",
|
||||
"sha256:ddbb2551d7e0102e7252db79ba445cdab71b26640817ab1e3e3648dad515003b",
|
||||
"sha256:deb6be0ac38ece9ba87dea880e438f25ca3eddfac8b002a2ec3d9183a454e8ae",
|
||||
"sha256:e06ed3eb3218bc64786f7db41917d4e686cc4856944f53d5bdf83a6884432e12",
|
||||
"sha256:e27ad930a842b4c5eb8ac0016b0a54f5aebbe679340c26101df33424142c143c",
|
||||
"sha256:e537484df0d8f426ce2afb2d0f8e1c3d0b114b83f8850e5f2fbea0e797bd82ae",
|
||||
"sha256:eb00ed941194665c332bf8e078baf037d6c35d7c4f3102ea2d4f16ca94a26dc8",
|
||||
"sha256:eb6904c354526e758fda7167b33005998fb68c46fbc10e013ca97f21ca5c8887",
|
||||
"sha256:eb8821e09e916165e160797a6c17edda0679379a4be5c716c260e836e122f54b",
|
||||
"sha256:efcb3f6676480691518c177e3b465bcddf57cea040302f9f4e6e191af91174d4",
|
||||
"sha256:f27273b60488abe721a075bcca6d7f3964f9f6f067c8c4c605743023d7d3944f",
|
||||
"sha256:f30c3cb33b24454a82faecaf01b19c18562b1e89558fb6c56de4d9118a032fd5",
|
||||
"sha256:fb69256e180cb6c8a894fee62b3afebae785babc1ee98b81cdf68bbca1987f33",
|
||||
"sha256:fd1abc0d89e30cc4e02e4064dc67fcc51bd941eb395c502aac3ec19fab46b519",
|
||||
"sha256:ff8fa367d09b717b2a17a052544193ad76cd49979c805768879cb63d9ca50561"
|
||||
],
|
||||
"markers": "python_full_version >= '3.7.0'",
|
||||
"version": "==3.3.2"
|
||||
},
|
||||
"defusedxml": {
|
||||
"hashes": [
|
||||
"sha256:1bb3032db185915b62d7c6209c5a8792be6a32ab2fedacc84e01b52c51aa3e69",
|
||||
"sha256:a352e7e428770286cc899e2542b6cdaedb2b4953ff269a210103ec58f6198a61"
|
||||
],
|
||||
"markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3, 3.4'",
|
||||
"version": "==0.7.1"
|
||||
},
|
||||
"idna": {
|
||||
"hashes": [
|
||||
"sha256:9ecdbbd083b06798ae1e86adcbfe8ab1479cf864e4ee30fe4e46a003d12491ca",
|
||||
"sha256:c05567e9c24a6b9faaa835c4821bad0590fbb9d5779e7caa6e1cc4978e7eb24f"
|
||||
],
|
||||
"markers": "python_version >= '3.5'",
|
||||
"version": "==3.6"
|
||||
},
|
||||
"ifaddr": {
|
||||
"hashes": [
|
||||
"sha256:085e0305cfe6f16ab12d72e2024030f5d52674afad6911bb1eee207177b8a748",
|
||||
"sha256:cc0cbfcaabf765d44595825fb96a99bb12c79716b73b44330ea38ee2b0c4aed4"
|
||||
],
|
||||
"version": "==0.2.0"
|
||||
},
|
||||
"librespot": {
|
||||
"git": "git+https://github.com/kokarare1212/librespot-python",
|
||||
"ref": "f56533f9b56e62b28bac6c57d0710620aeb6a5dd"
|
||||
},
|
||||
"music-tag": {
|
||||
"git": "git+https://zotify.xyz/zotify/music-tag",
|
||||
"ref": "5c73ddf11a6d65d6575c0e1bb8cce8413f46a433"
|
||||
},
|
||||
"mutagen": {
|
||||
"hashes": [
|
||||
"sha256:719fadef0a978c31b4cf3c956261b3c58b6948b32023078a2117b1de09f0fc99",
|
||||
"sha256:edd96f50c5907a9539d8e5bba7245f62c9f520aef333d13392a79a4f70aca719"
|
||||
],
|
||||
"index": "pypi",
|
||||
"markers": "python_version >= '3.7'",
|
||||
"version": "==1.47.0"
|
||||
},
|
||||
"pillow": {
|
||||
"hashes": [
|
||||
"sha256:0304004f8067386b477d20a518b50f3fa658a28d44e4116970abfcd94fac34a8",
|
||||
"sha256:0689b5a8c5288bc0504d9fcee48f61a6a586b9b98514d7d29b840143d6734f39",
|
||||
"sha256:0eae2073305f451d8ecacb5474997c08569fb4eb4ac231ffa4ad7d342fdc25ac",
|
||||
"sha256:0fb3e7fc88a14eacd303e90481ad983fd5b69c761e9e6ef94c983f91025da869",
|
||||
"sha256:11fa2e5984b949b0dd6d7a94d967743d87c577ff0b83392f17cb3990d0d2fd6e",
|
||||
"sha256:127cee571038f252a552760076407f9cff79761c3d436a12af6000cd182a9d04",
|
||||
"sha256:154e939c5f0053a383de4fd3d3da48d9427a7e985f58af8e94d0b3c9fcfcf4f9",
|
||||
"sha256:15587643b9e5eb26c48e49a7b33659790d28f190fc514a322d55da2fb5c2950e",
|
||||
"sha256:170aeb00224ab3dc54230c797f8404507240dd868cf52066f66a41b33169bdbe",
|
||||
"sha256:1b5e1b74d1bd1b78bc3477528919414874748dd363e6272efd5abf7654e68bef",
|
||||
"sha256:1da3b2703afd040cf65ec97efea81cfba59cdbed9c11d8efc5ab09df9509fc56",
|
||||
"sha256:1e23412b5c41e58cec602f1135c57dfcf15482013ce6e5f093a86db69646a5aa",
|
||||
"sha256:2247178effb34a77c11c0e8ac355c7a741ceca0a732b27bf11e747bbc950722f",
|
||||
"sha256:257d8788df5ca62c980314053197f4d46eefedf4e6175bc9412f14412ec4ea2f",
|
||||
"sha256:3031709084b6e7852d00479fd1d310b07d0ba82765f973b543c8af5061cf990e",
|
||||
"sha256:322209c642aabdd6207517e9739c704dc9f9db943015535783239022002f054a",
|
||||
"sha256:322bdf3c9b556e9ffb18f93462e5f749d3444ce081290352c6070d014c93feb2",
|
||||
"sha256:33870dc4653c5017bf4c8873e5488d8f8d5f8935e2f1fb9a2208c47cdd66efd2",
|
||||
"sha256:35bb52c37f256f662abdfa49d2dfa6ce5d93281d323a9af377a120e89a9eafb5",
|
||||
"sha256:3c31822339516fb3c82d03f30e22b1d038da87ef27b6a78c9549888f8ceda39a",
|
||||
"sha256:3eedd52442c0a5ff4f887fab0c1c0bb164d8635b32c894bc1faf4c618dd89df2",
|
||||
"sha256:3ff074fc97dd4e80543a3e91f69d58889baf2002b6be64347ea8cf5533188213",
|
||||
"sha256:47c0995fc4e7f79b5cfcab1fc437ff2890b770440f7696a3ba065ee0fd496563",
|
||||
"sha256:49d9ba1ed0ef3e061088cd1e7538a0759aab559e2e0a80a36f9fd9d8c0c21591",
|
||||
"sha256:51f1a1bffc50e2e9492e87d8e09a17c5eea8409cda8d3f277eb6edc82813c17c",
|
||||
"sha256:52a50aa3fb3acb9cf7213573ef55d31d6eca37f5709c69e6858fe3bc04a5c2a2",
|
||||
"sha256:54f1852cd531aa981bc0965b7d609f5f6cc8ce8c41b1139f6ed6b3c54ab82bfb",
|
||||
"sha256:609448742444d9290fd687940ac0b57fb35e6fd92bdb65386e08e99af60bf757",
|
||||
"sha256:69ffdd6120a4737710a9eee73e1d2e37db89b620f702754b8f6e62594471dee0",
|
||||
"sha256:6fad5ff2f13d69b7e74ce5b4ecd12cc0ec530fcee76356cac6742785ff71c452",
|
||||
"sha256:7049e301399273a0136ff39b84c3678e314f2158f50f517bc50285fb5ec847ad",
|
||||
"sha256:70c61d4c475835a19b3a5aa42492409878bbca7438554a1f89d20d58a7c75c01",
|
||||
"sha256:716d30ed977be8b37d3ef185fecb9e5a1d62d110dfbdcd1e2a122ab46fddb03f",
|
||||
"sha256:753cd8f2086b2b80180d9b3010dd4ed147efc167c90d3bf593fe2af21265e5a5",
|
||||
"sha256:773efe0603db30c281521a7c0214cad7836c03b8ccff897beae9b47c0b657d61",
|
||||
"sha256:7823bdd049099efa16e4246bdf15e5a13dbb18a51b68fa06d6c1d4d8b99a796e",
|
||||
"sha256:7c8f97e8e7a9009bcacbe3766a36175056c12f9a44e6e6f2d5caad06dcfbf03b",
|
||||
"sha256:823ef7a27cf86df6597fa0671066c1b596f69eba53efa3d1e1cb8b30f3533068",
|
||||
"sha256:8373c6c251f7ef8bda6675dd6d2b3a0fcc31edf1201266b5cf608b62a37407f9",
|
||||
"sha256:83b2021f2ade7d1ed556bc50a399127d7fb245e725aa0113ebd05cfe88aaf588",
|
||||
"sha256:870ea1ada0899fd0b79643990809323b389d4d1d46c192f97342eeb6ee0b8483",
|
||||
"sha256:8d12251f02d69d8310b046e82572ed486685c38f02176bd08baf216746eb947f",
|
||||
"sha256:9c23f307202661071d94b5e384e1e1dc7dfb972a28a2310e4ee16103e66ddb67",
|
||||
"sha256:9d189550615b4948f45252d7f005e53c2040cea1af5b60d6f79491a6e147eef7",
|
||||
"sha256:a086c2af425c5f62a65e12fbf385f7c9fcb8f107d0849dba5839461a129cf311",
|
||||
"sha256:a2b56ba36e05f973d450582fb015594aaa78834fefe8dfb8fcd79b93e64ba4c6",
|
||||
"sha256:aebb6044806f2e16ecc07b2a2637ee1ef67a11840a66752751714a0d924adf72",
|
||||
"sha256:b1b3020d90c2d8e1dae29cf3ce54f8094f7938460fb5ce8bc5c01450b01fbaf6",
|
||||
"sha256:b4b6b1e20608493548b1f32bce8cca185bf0480983890403d3b8753e44077129",
|
||||
"sha256:b6f491cdf80ae540738859d9766783e3b3c8e5bd37f5dfa0b76abdecc5081f13",
|
||||
"sha256:b792a349405fbc0163190fde0dc7b3fef3c9268292586cf5645598b48e63dc67",
|
||||
"sha256:b7c2286c23cd350b80d2fc9d424fc797575fb16f854b831d16fd47ceec078f2c",
|
||||
"sha256:babf5acfede515f176833ed6028754cbcd0d206f7f614ea3447d67c33be12516",
|
||||
"sha256:c365fd1703040de1ec284b176d6af5abe21b427cb3a5ff68e0759e1e313a5e7e",
|
||||
"sha256:c4225f5220f46b2fde568c74fca27ae9771536c2e29d7c04f4fb62c83275ac4e",
|
||||
"sha256:c570f24be1e468e3f0ce7ef56a89a60f0e05b30a3669a459e419c6eac2c35364",
|
||||
"sha256:c6dafac9e0f2b3c78df97e79af707cdc5ef8e88208d686a4847bab8266870023",
|
||||
"sha256:c8de2789052ed501dd829e9cae8d3dcce7acb4777ea4a479c14521c942d395b1",
|
||||
"sha256:cb28c753fd5eb3dd859b4ee95de66cc62af91bcff5db5f2571d32a520baf1f04",
|
||||
"sha256:cb4c38abeef13c61d6916f264d4845fab99d7b711be96c326b84df9e3e0ff62d",
|
||||
"sha256:d1b35bcd6c5543b9cb547dee3150c93008f8dd0f1fef78fc0cd2b141c5baf58a",
|
||||
"sha256:d8e6aeb9201e655354b3ad049cb77d19813ad4ece0df1249d3c793de3774f8c7",
|
||||
"sha256:d8ecd059fdaf60c1963c58ceb8997b32e9dc1b911f5da5307aab614f1ce5c2fb",
|
||||
"sha256:da2b52b37dad6d9ec64e653637a096905b258d2fc2b984c41ae7d08b938a67e4",
|
||||
"sha256:e87f0b2c78157e12d7686b27d63c070fd65d994e8ddae6f328e0dcf4a0cd007e",
|
||||
"sha256:edca80cbfb2b68d7b56930b84a0e45ae1694aeba0541f798e908a49d66b837f1",
|
||||
"sha256:f379abd2f1e3dddb2b61bc67977a6b5a0a3f7485538bcc6f39ec76163891ee48",
|
||||
"sha256:fe4c15f6c9285dc54ce6553a3ce908ed37c8f3825b5a51a15c91442bb955b868"
|
||||
],
|
||||
"index": "pypi",
|
||||
"markers": "python_version >= '3.8'",
|
||||
"version": "==10.2.0"
|
||||
},
|
||||
"protobuf": {
|
||||
"hashes": [
|
||||
"sha256:06059eb6953ff01e56a25cd02cca1a9649a75a7e65397b5b9b4e929ed71d10cf",
|
||||
"sha256:097c5d8a9808302fb0da7e20edf0b8d4703274d140fd25c5edabddcde43e081f",
|
||||
"sha256:284f86a6207c897542d7e956eb243a36bb8f9564c1742b253462386e96c6b78f",
|
||||
"sha256:32ca378605b41fd180dfe4e14d3226386d8d1b002ab31c969c366549e66a2bb7",
|
||||
"sha256:3cc797c9d15d7689ed507b165cd05913acb992d78b379f6014e013f9ecb20996",
|
||||
"sha256:62f1b5c4cd6c5402b4e2d63804ba49a327e0c386c99b1675c8a0fefda23b2067",
|
||||
"sha256:69ccfdf3657ba59569c64295b7d51325f91af586f8d5793b734260dfe2e94e2c",
|
||||
"sha256:6f50601512a3d23625d8a85b1638d914a0970f17920ff39cec63aaef80a93fb7",
|
||||
"sha256:7403941f6d0992d40161aa8bb23e12575637008a5a02283a930addc0508982f9",
|
||||
"sha256:755f3aee41354ae395e104d62119cb223339a8f3276a0cd009ffabfcdd46bb0c",
|
||||
"sha256:77053d28427a29987ca9caf7b72ccafee011257561259faba8dd308fda9a8739",
|
||||
"sha256:7e371f10abe57cee5021797126c93479f59fccc9693dafd6bd5633ab67808a91",
|
||||
"sha256:9016d01c91e8e625141d24ec1b20fed584703e527d28512aa8c8707f105a683c",
|
||||
"sha256:9be73ad47579abc26c12024239d3540e6b765182a91dbc88e23658ab71767153",
|
||||
"sha256:adc31566d027f45efe3f44eeb5b1f329da43891634d61c75a5944e9be6dd42c9",
|
||||
"sha256:adfc6cf69c7f8c50fd24c793964eef18f0ac321315439d94945820612849c388",
|
||||
"sha256:af0ebadc74e281a517141daad9d0f2c5d93ab78e9d455113719a45a49da9db4e",
|
||||
"sha256:cb29edb9eab15742d791e1025dd7b6a8f6fcb53802ad2f6e3adcb102051063ab",
|
||||
"sha256:cd68be2559e2a3b84f517fb029ee611546f7812b1fdd0aa2ecc9bc6ec0e4fdde",
|
||||
"sha256:cdee09140e1cd184ba9324ec1df410e7147242b94b5f8b0c64fc89e38a8ba531",
|
||||
"sha256:db977c4ca738dd9ce508557d4fce0f5aebd105e158c725beec86feb1f6bc20d8",
|
||||
"sha256:dd5789b2948ca702c17027c84c2accb552fc30f4622a98ab5c51fcfe8c50d3e7",
|
||||
"sha256:e250a42f15bf9d5b09fe1b293bdba2801cd520a9f5ea2d7fb7536d4441811d20",
|
||||
"sha256:ff8d8fa42675249bb456f5db06c00de6c2f4c27a065955917b28c4f15978b9c3"
|
||||
],
|
||||
"markers": "python_version >= '3.7'",
|
||||
"version": "==3.20.1"
|
||||
},
|
||||
"pwinput": {
|
||||
"hashes": [
|
||||
"sha256:ca1a8bd06e28872d751dbd4132d8637127c25b408ea3a349377314a5491426f3"
|
||||
],
|
||||
"index": "pypi",
|
||||
"version": "==1.0.3"
|
||||
},
|
||||
"pycryptodomex": {
|
||||
"hashes": [
|
||||
"sha256:0daad007b685db36d977f9de73f61f8da2a7104e20aca3effd30752fd56f73e1",
|
||||
"sha256:108e5f1c1cd70ffce0b68739c75734437c919d2eaec8e85bffc2c8b4d2794305",
|
||||
"sha256:19764605feea0df966445d46533729b645033f134baeb3ea26ad518c9fdf212c",
|
||||
"sha256:1be97461c439a6af4fe1cf8bf6ca5936d3db252737d2f379cc6b2e394e12a458",
|
||||
"sha256:25cd61e846aaab76d5791d006497134602a9e451e954833018161befc3b5b9ed",
|
||||
"sha256:2a47bcc478741b71273b917232f521fd5704ab4b25d301669879e7273d3586cc",
|
||||
"sha256:59af01efb011b0e8b686ba7758d59cf4a8263f9ad35911bfe3f416cee4f5c08c",
|
||||
"sha256:5dcac11031a71348faaed1f403a0debd56bf5404232284cf8c761ff918886ebc",
|
||||
"sha256:62a5ec91388984909bb5398ea49ee61b68ecb579123694bffa172c3b0a107079",
|
||||
"sha256:645bd4ca6f543685d643dadf6a856cc382b654cc923460e3a10a49c1b3832aeb",
|
||||
"sha256:653b29b0819605fe0898829c8ad6400a6ccde096146730c2da54eede9b7b8baa",
|
||||
"sha256:69138068268127cd605e03438312d8f271135a33140e2742b417d027a0539427",
|
||||
"sha256:6e186342cfcc3aafaad565cbd496060e5a614b441cacc3995ef0091115c1f6c5",
|
||||
"sha256:76bd15bb65c14900d98835fcd10f59e5e0435077431d3a394b60b15864fddd64",
|
||||
"sha256:7805830e0c56d88f4d491fa5ac640dfc894c5ec570d1ece6ed1546e9df2e98d6",
|
||||
"sha256:7a710b79baddd65b806402e14766c721aee8fb83381769c27920f26476276c1e",
|
||||
"sha256:7a7a8f33a1f1fb762ede6cc9cbab8f2a9ba13b196bfaf7bc6f0b39d2ba315a43",
|
||||
"sha256:82ee7696ed8eb9a82c7037f32ba9b7c59e51dda6f105b39f043b6ef293989cb3",
|
||||
"sha256:88afd7a3af7ddddd42c2deda43d53d3dfc016c11327d0915f90ca34ebda91499",
|
||||
"sha256:8af1a451ff9e123d0d8bd5d5e60f8e3315c3a64f3cdd6bc853e26090e195cdc8",
|
||||
"sha256:8ee606964553c1a0bc74057dd8782a37d1c2bc0f01b83193b6f8bb14523b877b",
|
||||
"sha256:91852d4480a4537d169c29a9d104dda44094c78f1f5b67bca76c29a91042b623",
|
||||
"sha256:9c682436c359b5ada67e882fec34689726a09c461efd75b6ea77b2403d5665b7",
|
||||
"sha256:bc3ee1b4d97081260d92ae813a83de4d2653206967c4a0a017580f8b9548ddbc",
|
||||
"sha256:bca649483d5ed251d06daf25957f802e44e6bb6df2e8f218ae71968ff8f8edc4",
|
||||
"sha256:c39778fd0548d78917b61f03c1fa8bfda6cfcf98c767decf360945fe6f97461e",
|
||||
"sha256:cbe71b6712429650e3883dc81286edb94c328ffcd24849accac0a4dbcc76958a",
|
||||
"sha256:d00fe8596e1cc46b44bf3907354e9377aa030ec4cd04afbbf6e899fc1e2a7781",
|
||||
"sha256:d3584623e68a5064a04748fb6d76117a21a7cb5eaba20608a41c7d0c61721794",
|
||||
"sha256:e48217c7901edd95f9f097feaa0388da215ed14ce2ece803d3f300b4e694abea",
|
||||
"sha256:f2e497413560e03421484189a6b65e33fe800d3bd75590e6d78d4dfdb7accf3b",
|
||||
"sha256:ff5c9a67f8a4fba4aed887216e32cbc48f2a6fb2673bb10a99e43be463e15913"
|
||||
],
|
||||
"markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3, 3.4'",
|
||||
"version": "==3.20.0"
|
||||
},
|
||||
"pyogg": {
|
||||
"hashes": [
|
||||
"sha256:40f79b288b3a667309890885f4cf53371163b7dae17eb17567fb24ab467eca26",
|
||||
"sha256:794db340fb5833afb4f493b40f91e3e0f594606fd4b31aea0ebf5be2de9da964",
|
||||
"sha256:8294b34aa59c90200c4630c2cc4a5b84407209141e8e5d069d7a5be358e94262"
|
||||
],
|
||||
"version": "==0.6.14a1"
|
||||
},
|
||||
"requests": {
|
||||
"hashes": [
|
||||
"sha256:58cd2187c01e70e6e26505bca751777aa9f2ee0b7f4300988b709f44e013003f",
|
||||
"sha256:942c5a758f98d790eaed1a29cb6eefc7ffb0d1cf7af05c3d2791656dbd6ad1e1"
|
||||
],
|
||||
"index": "pypi",
|
||||
"markers": "python_version >= '3.7'",
|
||||
"version": "==2.31.0"
|
||||
},
|
||||
"tqdm": {
|
||||
"hashes": [
|
||||
"sha256:d302b3c5b53d47bce91fea46679d9c3c6508cf6332229aa1e7d8653723793386",
|
||||
"sha256:d88e651f9db8d8551a62556d3cff9e3034274ca5d66e93197cf2490e2dcb69c7"
|
||||
],
|
||||
"index": "pypi",
|
||||
"markers": "python_version >= '3.7'",
|
||||
"version": "==4.66.1"
|
||||
},
|
||||
"urllib3": {
|
||||
"hashes": [
|
||||
"sha256:051d961ad0c62a94e50ecf1af379c3aba230c66c710493493560c0c223c49f20",
|
||||
"sha256:ce3711610ddce217e6d113a2732fafad960a03fd0318c91faa79481e35c11224"
|
||||
],
|
||||
"markers": "python_version >= '3.8'",
|
||||
"version": "==2.2.0"
|
||||
},
|
||||
"websocket-client": {
|
||||
"hashes": [
|
||||
"sha256:10e511ea3a8c744631d3bd77e61eb17ed09304c413ad42cf6ddfa4c7787e8fe6",
|
||||
"sha256:f4c3d22fec12a2461427a29957ff07d35098ee2d976d3ba244e688b8b4057588"
|
||||
],
|
||||
"markers": "python_version >= '3.8'",
|
||||
"version": "==1.7.0"
|
||||
},
|
||||
"zeroconf": {
|
||||
"hashes": [
|
||||
"sha256:0251034ed1d57eeb4e08782b22cc51e2455da7552b592bfad69a5761e69241c7",
|
||||
"sha256:02e3b6d1c1df87e8bc450de3f973ab9f4cfd1b4c0a3fb9e933d84580a1d61263",
|
||||
"sha256:08eb87b0500ddc7c148fe3db3913e9d07d5495d756d7d75683f2dee8d7a09dc5",
|
||||
"sha256:10e8d23cee434077a10ceec4b419b9de8c84ede7f42b64e735d0f0b7708b0c66",
|
||||
"sha256:14f0bef6b4f7bd0caf80f207acd1e399e8d8a37e12266d80871a2ed6c9ee3b16",
|
||||
"sha256:18ff5b28e8935e5399fe47ece323e15816bc2ea4111417c41fc09726ff056cd2",
|
||||
"sha256:194cf1465a756c3090e23ef2a5bd3341caa8d36eef486054daa8e532a4e24ac8",
|
||||
"sha256:1a57e0c4a94276ec690d2ecf1edeea158aaa3a7f38721af6fa572776dda6c8ad",
|
||||
"sha256:2389e3a61e99bf74796da7ebc3001b90ecd4e6286f392892b1211748e5b19853",
|
||||
"sha256:24b0a46c5f697cd6a0b27678ea65a3222b95f1804be6b38c6f5f1a7ce8b5cded",
|
||||
"sha256:28d906fc0779badb2183f5b20dbcc7e508cce53a13e63ba4d9477381c9f77463",
|
||||
"sha256:2907784c8c88795bf1b74cc9b6a4051e37a519ae2caaa7307787d466bc57884c",
|
||||
"sha256:34c3379d899361cd9d6b573ea9ac1eba53e2306eb28f94353b58c4703f0e74ae",
|
||||
"sha256:3768ab13a8d7f0df85e40e766edd9e2aef28710a350dc4b15e1f2c5dd1326f00",
|
||||
"sha256:38bfd08c9191716d65e6ac52741442ee918bfe2db43993aa4d3b365966c0ab48",
|
||||
"sha256:3a49aaff22bc576680b4bcb3c7de896587f6ab4adaa788bedbc468dd0ad28cce",
|
||||
"sha256:3b167b9e47f3fec8cc28a8f73a9e47c563ceb6681c16dcbe2c7d41e084cee755",
|
||||
"sha256:3bc16228495e67ec990668970e815b341160258178c21b7716400c5e7a78976a",
|
||||
"sha256:3f49ec4e8d5bd860e9958e88e8b312e31828f5cb2203039390c551f3fb0b45dd",
|
||||
"sha256:434344df3037df08bad7422d5d36a415f30ddcc29ac1ad0cc0160b4976b782b5",
|
||||
"sha256:4713e5cd986f9467494e5b47b0149ac0ffd7ad630d78cd6f6d2555b199e5a653",
|
||||
"sha256:4865ef65b7eb7eee1a38c05bf7e91dd8182ef2afb1add65440f99e8dd43836d2",
|
||||
"sha256:52b65e5eeacae121695bcea347cc9ad7da5556afcd3765c461e652ca3e8a84e9",
|
||||
"sha256:551c04799325c890f2baa347e82cd2c3fb1d01b14940d7695f27c49cd2413b0c",
|
||||
"sha256:5d777b177cb472f7996b9d696b81337bfb846dbe454b8a34a8e33704d3a435b0",
|
||||
"sha256:6a041468c428622798193f0006831237aa749ee23e26b5b79e457618484457ef",
|
||||
"sha256:6c55a1627290ba0718022fb63cf5a25d773c52b00319ef474dd443ebe92efab1",
|
||||
"sha256:7c4235f45defd43bb2402ff8d3c7ff5d740e671bfd926852541c282ebef992bc",
|
||||
"sha256:8642d374481d8cc7be9e364b82bcd11bda4a095c24c5f9f5754017a118496b77",
|
||||
"sha256:90c431e99192a044a5e0217afd7ca0ca9824af93190332e6f7baf4da5375f331",
|
||||
"sha256:9a7f3b9a580af6bf74a7c435b80925dfeb065c987dffaf4d957d578366a80b2c",
|
||||
"sha256:9dfa3d8827efffebec61b108162eeb76b0fe170a8379f9838be441f61b4557fd",
|
||||
"sha256:a3f1d959e3a57afa6b383eb880048929473507b1cc0e8b5e1a72ddf0fc1bbb77",
|
||||
"sha256:a613827f97ca49e2b4b6d6eb7e61a0485afe23447978a60f42b981a45c2b25fd",
|
||||
"sha256:a984c93aa413a594f048ef7166f0d9be73b0cd16dfab1395771b7c0607e07817",
|
||||
"sha256:b843d5e2d2e576efeab59e382907bca1302f20eb33ee1a0a485e90d017b1088a",
|
||||
"sha256:bdb1a2a67e34059e69aaead600525e91c126c46502ada1c7fc3d2c082cc8ad27",
|
||||
"sha256:bf9ec50ffdf4e179c035f96a106a5c510d5295c5fb7e2e69dd4cda7b7f42f8bf",
|
||||
"sha256:c10158396d6875f790bfb5600391d44edcbf52ac4d148e19baab3e8bb7825f76",
|
||||
"sha256:c3f0f87e47e4d5a9bcfcfc1ce29d0e9127a5cab63e839cc6f845c563f29d765c",
|
||||
"sha256:c75bb2c1e472723067c7ec986ea510350c335bf8e73ad12617fc6a9ec765dc4b",
|
||||
"sha256:cb2879708357cac9805d20944973f3d50b472c703b8eaadd9bf136024c5539b4",
|
||||
"sha256:cc7a76103b03f47d2aa02206f74cc8b2120f4bac02936ccee5d6f29290f5bde5",
|
||||
"sha256:ce67d8dab4d88bcd1e5975d08235590fc5b9f31b2e2b7993ee1680810e67e56d",
|
||||
"sha256:d08170123f5c04480bd7a82122b46c5afdb91553a9cef7d686d3fb9c369a9204",
|
||||
"sha256:d4baa0450b9b0f1bd8acc25c2970d4e49e54726cbc437b81ffb65e5ffb6bd321",
|
||||
"sha256:d5d92987c3669edbfa9f911a8ef1c46cfd2c3e51971fc80c215f99212b81d4b1",
|
||||
"sha256:e0d1357940b590466bc72ac605e6ad3f7f05b2e1475b6896ec8e4c61e4d23034",
|
||||
"sha256:e7d51df61579862414ac544f2892ea3c91a6b45dd728d4fb6260d65bf6f1ef0f",
|
||||
"sha256:f74149a22a6a27e4c039f6477188dcbcb910acd60529dab5c114ff6265d40ba7",
|
||||
"sha256:fdcb9cb0555c7947f29a4d5c05c98e260a04f37d6af31aede1e981bf1bdf8691"
|
||||
],
|
||||
"markers": "python_version >= '3.7' and python_version < '4.0'",
|
||||
"version": "==0.131.0"
|
||||
}
|
||||
},
|
||||
"develop": {}
|
||||
}
|
59
README.md
59
README.md
|
@ -1,13 +1,12 @@
|
|||
# STILL IN DEVELOPMENT, NOT RECOMMENDED FOR GENERAL USE!
|
||||
|
||||
![Logo banner](https://s1.fileditch.ch/hOwJhfeCFEsYFRWUWaz.png)
|
||||
![Logo banner](./assets/banner.png)
|
||||
|
||||
# Zotify
|
||||
|
||||
A customizable music and podcast downloader. \
|
||||
Formerly ZSpotify.
|
||||
|
||||
Available on [zotify.xyz](https://zotify.xyz/zotify/zotify) and [GitHub](https://github.com/zotify-dev/zotify).
|
||||
Available on [zotify.xyz](https://zotify.xyz/zotify/zotify) and [GitHub](https://github.com/zotify-dev/zotify). \
|
||||
Built on [Librespot](https://github.com/kokarare1212/librespot-python).
|
||||
|
||||
## Features
|
||||
|
||||
|
@ -48,23 +47,23 @@ Downloads specified items. Accepts any combination of track, album, playlist, ep
|
|||
|
||||
<details><summary>All configuration options</summary>
|
||||
|
||||
| Config key | Command line argument | Description |
|
||||
| ----------------------- | ------------------------- | --------------------------------------------------- |
|
||||
| path_credentials | --path-credentials | Path to credentials file |
|
||||
| path_archive | --path-archive | Path to track archive file |
|
||||
| music_library | --music-library | Path to root of music library |
|
||||
| podcast_library | --podcast-library | Path to root of podcast library |
|
||||
| mixed_playlist_library | --mixed-playlist-library | Path to root of mixed content playlist library |
|
||||
| output_album | --output-album | File layout for saved albums |
|
||||
| output_playlist_track | --output-playlist-track | File layout for tracks in a playlist |
|
||||
| output_playlist_episode | --output-playlist-episode | File layout for episodes in a playlist |
|
||||
| output_podcast | --output-podcast | File layout for saved podcasts |
|
||||
| download_quality | --download-quality | Audio download quality (auto for highest available) |
|
||||
| audio_format | --audio-format | Audio format of final track output |
|
||||
| transcode_bitrate | --transcode-bitrate | Transcoding bitrate (-1 to use download rate) |
|
||||
| ffmpeg_path | --ffmpeg-path | Path to ffmpeg binary |
|
||||
| ffmpeg_args | --ffmpeg-args | Additional ffmpeg arguments when transcoding |
|
||||
| save_credentials | --save-credentials | Save login credentials to a file |
|
||||
| Config key | Command line argument | Description | Default |
|
||||
| ----------------------- | ------------------------- | --------------------------------------------------- | ---------------------------------------------------------- |
|
||||
| path_credentials | --path-credentials | Path to credentials file | |
|
||||
| path_archive | --path-archive | Path to track archive file | |
|
||||
| music_library | --music-library | Path to root of music library | |
|
||||
| podcast_library | --podcast-library | Path to root of podcast library | |
|
||||
| mixed_playlist_library | --mixed-playlist-library | Path to root of mixed content playlist library | |
|
||||
| output_album | --output-album | File layout for saved albums | {album_artist}/{album}/{track_number}. {artists} - {title} |
|
||||
| output_playlist_track | --output-playlist-track | File layout for tracks in a playlist | {playlist}/{playlist_number}. {artists} - {title} |
|
||||
| output_playlist_episode | --output-playlist-episode | File layout for episodes in a playlist | {playlist}/{playlist_number}. {episode_number} - {title} |
|
||||
| output_podcast | --output-podcast | File layout for saved podcasts | {podcast}/{episode_number} - {title} |
|
||||
| download_quality | --download-quality | Audio download quality (auto for highest available) | |
|
||||
| audio_format | --audio-format | Audio format of final track output | |
|
||||
| transcode_bitrate | --transcode-bitrate | Transcoding bitrate (-1 to use download rate) | |
|
||||
| ffmpeg_path | --ffmpeg-path | Path to ffmpeg binary | |
|
||||
| ffmpeg_args | --ffmpeg-args | Additional ffmpeg arguments when transcoding | |
|
||||
| save_credentials | --save-credentials | Save login credentials to a file | |
|
||||
| save_subtitles | --save-subtitles |
|
||||
| save_artist_genres | --save-arist-genres |
|
||||
|
||||
|
@ -91,9 +90,9 @@ Zotify can be used as a user-friendly library for saving music, podcasts, lyrics
|
|||
Here's a very simple example of downloading a track and its metadata:
|
||||
|
||||
```python
|
||||
import zotify
|
||||
from zotify import Session
|
||||
|
||||
session = zotify.Session.from_userpass(username="username", password="password")
|
||||
session = Session.from_userpass(username="username", password="password")
|
||||
track = session.get_track("4cOdK2wGLETKBW3PvgPWqT")
|
||||
output = track.create_output("./Music", "{artist} - {title}")
|
||||
|
||||
|
@ -113,20 +112,14 @@ All new contributions should follow this principle to keep the program consisten
|
|||
|
||||
## Will my account get banned if I use this tool?
|
||||
|
||||
No user has reported their account getting banned after using Zotify
|
||||
There have been no confirmed cases of accounts getting banned as a result of using Zotify.
|
||||
However, it is still a possiblity and it is recommended you use Zotify with a burner account where possible.
|
||||
|
||||
Consider using [Exportify](https://github.com/watsonbox/exportify) to keep backups of your playlists.
|
||||
Consider using [Exportify](https://watsonbox.github.io/exportify/) to keep backups of your playlists.
|
||||
|
||||
## Disclaimer
|
||||
|
||||
Using Zotify violates Spotify user guidelines and may get your account suspended.
|
||||
|
||||
Zotify is intended to be used in compliance with DMCA, Section 1201, for educational, private and fair use, or any simlar laws in other regions. \
|
||||
Zotify contributors cannot be held liable for damages caused by the use of this tool. See the [LICENSE](./LICENCE) file for more details.
|
||||
|
||||
## Acknowledgements
|
||||
|
||||
- [Librespot-Python](https://github.com/kokarare1212/librespot-python) does most of the heavy lifting, it's used for authentication, fetching track data, and audio streaming.
|
||||
- [music-tag](https://github.com/KristoforMaynard/music-tag) is used for writing metadata into the downloaded files.
|
||||
- [FFmpeg](https://ffmpeg.org/) is used for transcoding audio.
|
||||
Zotify is intended to be used in compliance with DMCA, Section 1201, for educational, private and fair use, or any simlar laws in other regions.
|
||||
Zotify contributors are not liable for damages caused by the use of this tool. See the [LICENSE](./LICENCE) file for more details.
|
||||
|
|
BIN
assets/banner.png
Normal file
BIN
assets/banner.png
Normal file
Binary file not shown.
After Width: | Height: | Size: 110 KiB |
|
@ -2,5 +2,6 @@ black
|
|||
flake8
|
||||
mypy
|
||||
pre-commit
|
||||
types-protobuf
|
||||
types-requests
|
||||
wheel
|
||||
|
|
|
@ -1,6 +1,6 @@
|
|||
[metadata]
|
||||
name = zotify
|
||||
version = 0.9.2
|
||||
version = 0.9.4
|
||||
author = Zotify Contributors
|
||||
description = A highly customizable music and podcast downloader
|
||||
long_description = file: README.md
|
||||
|
@ -33,6 +33,10 @@ console_scripts =
|
|||
|
||||
[flake8]
|
||||
max-line-length = 160
|
||||
ignore =
|
||||
E701
|
||||
E704
|
||||
W503
|
||||
|
||||
[mypy]
|
||||
warn_unused_configs = True
|
||||
|
@ -43,6 +47,9 @@ ignore_missing_imports = True
|
|||
[mypy-music_tag]
|
||||
ignore_missing_imports = True
|
||||
|
||||
[mypy-mutagen.*]
|
||||
ignore_missing_imports = True
|
||||
|
||||
[mypy-pwinput]
|
||||
ignore_missing_imports = True
|
||||
|
||||
|
|
|
@ -3,24 +3,25 @@ from __future__ import annotations
|
|||
from pathlib import Path
|
||||
|
||||
from librespot.audio.decoders import VorbisOnlyAudioQuality
|
||||
from librespot.core import ApiClient, PlayableContentFeeder
|
||||
from librespot.core import ApiClient, ApResolver, PlayableContentFeeder
|
||||
from librespot.core import Session as LibrespotSession
|
||||
from librespot.metadata import EpisodeId, PlayableId, TrackId
|
||||
from pwinput import pwinput
|
||||
from requests import HTTPError, get
|
||||
|
||||
from zotify.loader import Loader
|
||||
from zotify.playable import Episode, Track
|
||||
from zotify.utils import API_URL, Quality
|
||||
from zotify.utils import Quality
|
||||
|
||||
API_URL = "https://api.sp" + "otify.com/v1/"
|
||||
|
||||
|
||||
class Api(ApiClient):
|
||||
def __init__(self, session: LibrespotSession, language: str = "en"):
|
||||
def __init__(self, session: Session):
|
||||
super(Api, self).__init__(session)
|
||||
self.__session = session
|
||||
self.__language = language
|
||||
|
||||
def __get_token(self) -> str:
|
||||
"""Returns user's API token"""
|
||||
return (
|
||||
self.__session.tokens()
|
||||
.get_token(
|
||||
|
@ -40,25 +41,25 @@ class Api(ApiClient):
|
|||
offset: int = 0,
|
||||
) -> dict:
|
||||
"""
|
||||
Requests data from api
|
||||
Requests data from API
|
||||
Args:
|
||||
url: API url and to get data from
|
||||
url: API URL and to get data from
|
||||
params: parameters to be sent in the request
|
||||
limit: The maximum number of items in the response
|
||||
offset: The offset of the items returned
|
||||
Returns:
|
||||
Dictionary representation of json response
|
||||
Dictionary representation of JSON response
|
||||
"""
|
||||
headers = {
|
||||
"Authorization": f"Bearer {self.__get_token()}",
|
||||
"Accept": "application/json",
|
||||
"Accept-Language": self.__language,
|
||||
"Accept-Language": self.__session.language(),
|
||||
"app-platform": "WebPlayer",
|
||||
}
|
||||
params["limit"] = limit
|
||||
params["offset"] = offset
|
||||
|
||||
response = get(url, headers=headers, params=params)
|
||||
response = get(API_URL + url, headers=headers, params=params)
|
||||
data = response.json()
|
||||
|
||||
try:
|
||||
|
@ -69,30 +70,39 @@ class Api(ApiClient):
|
|||
return data
|
||||
|
||||
|
||||
class Session:
|
||||
class Session(LibrespotSession):
|
||||
def __init__(
|
||||
self,
|
||||
librespot_session: LibrespotSession,
|
||||
language: str = "en",
|
||||
self, session_builder: LibrespotSession.Builder, language: str = "en"
|
||||
) -> None:
|
||||
"""
|
||||
Authenticates user, saves credentials to a file and generates api token.
|
||||
Args:
|
||||
session_builder: An instance of the Librespot Session.Builder
|
||||
session_builder: An instance of the Librespot Session builder
|
||||
langauge: ISO 639-1 language code
|
||||
"""
|
||||
self.__session = librespot_session
|
||||
self.__language = language
|
||||
self.__api = Api(self.__session, language)
|
||||
self.__country = self.api().invoke_url(API_URL + "me")["country"]
|
||||
with Loader("Logging in..."):
|
||||
super(Session, self).__init__(
|
||||
LibrespotSession.Inner(
|
||||
session_builder.device_type,
|
||||
session_builder.device_name,
|
||||
session_builder.preferred_locale,
|
||||
session_builder.conf,
|
||||
session_builder.device_id,
|
||||
),
|
||||
ApResolver.get_random_accesspoint(),
|
||||
)
|
||||
self.connect()
|
||||
self.authenticate(session_builder.login_credentials)
|
||||
self.__api = Api(self)
|
||||
self.__language = language
|
||||
|
||||
@staticmethod
|
||||
def from_file(cred_file: Path, langauge: str = "en") -> Session:
|
||||
def from_file(cred_file: Path, language: str = "en") -> Session:
|
||||
"""
|
||||
Creates session using saved credentials file
|
||||
Args:
|
||||
cred_file: Path to credentials file
|
||||
langauge: ISO 639-1 language code for API responses
|
||||
language: ISO 639-1 language code for API responses
|
||||
Returns:
|
||||
Zotify session
|
||||
"""
|
||||
|
@ -102,12 +112,12 @@ class Session:
|
|||
.build()
|
||||
)
|
||||
session = LibrespotSession.Builder(conf).stored_file(str(cred_file))
|
||||
return Session(session.create(), langauge)
|
||||
return Session(session, language)
|
||||
|
||||
@staticmethod
|
||||
def from_userpass(
|
||||
username: str = "",
|
||||
password: str = "",
|
||||
username: str,
|
||||
password: str,
|
||||
save_file: Path | None = None,
|
||||
language: str = "en",
|
||||
) -> Session:
|
||||
|
@ -117,15 +127,10 @@ class Session:
|
|||
username: Account username
|
||||
password: Account password
|
||||
save_file: Path to save login credentials to, optional.
|
||||
langauge: ISO 639-1 language code for API responses
|
||||
language: ISO 639-1 language code for API responses
|
||||
Returns:
|
||||
Zotify session
|
||||
"""
|
||||
username = input("Username: ") if username == "" else username
|
||||
password = (
|
||||
pwinput(prompt="Password: ", mask="*") if password == "" else password
|
||||
)
|
||||
|
||||
builder = LibrespotSession.Configuration.Builder()
|
||||
if save_file:
|
||||
save_file.parent.mkdir(parents=True, exist_ok=True)
|
||||
|
@ -136,21 +141,35 @@ class Session:
|
|||
session = LibrespotSession.Builder(builder.build()).user_pass(
|
||||
username, password
|
||||
)
|
||||
return Session(session.create(), language)
|
||||
return Session(session, language)
|
||||
|
||||
@staticmethod
|
||||
def from_prompt(save_file: Path | None = None, language: str = "en") -> Session:
|
||||
"""
|
||||
Creates a session with username + password supplied from CLI prompt
|
||||
Args:
|
||||
save_file: Path to save login credentials to, optional.
|
||||
language: ISO 639-1 language code for API responses
|
||||
Returns:
|
||||
Zotify session
|
||||
"""
|
||||
username = input("Username: ")
|
||||
password = pwinput(prompt="Password: ", mask="*")
|
||||
return Session.from_userpass(username, password, save_file, language)
|
||||
|
||||
def __get_playable(
|
||||
self, playable_id: PlayableId, quality: Quality
|
||||
) -> PlayableContentFeeder.LoadedStream:
|
||||
if quality.value is None:
|
||||
quality = Quality.VERY_HIGH if self.is_premium() else Quality.HIGH
|
||||
return self.__session.content_feeder().load(
|
||||
return self.content_feeder().load(
|
||||
playable_id,
|
||||
VorbisOnlyAudioQuality(quality.value),
|
||||
False,
|
||||
None,
|
||||
)
|
||||
|
||||
def get_track(self, track_id: TrackId, quality: Quality = Quality.AUTO) -> Track:
|
||||
def get_track(self, track_id: str, quality: Quality = Quality.AUTO) -> Track:
|
||||
"""
|
||||
Gets track/episode data and audio stream
|
||||
Args:
|
||||
|
@ -159,9 +178,11 @@ class Session:
|
|||
Returns:
|
||||
Track object
|
||||
"""
|
||||
return Track(self.__get_playable(track_id, quality), self.api())
|
||||
return Track(
|
||||
self.__get_playable(TrackId.from_base62(track_id), quality), self.api()
|
||||
)
|
||||
|
||||
def get_episode(self, episode_id: EpisodeId) -> Episode:
|
||||
def get_episode(self, episode_id: str) -> Episode:
|
||||
"""
|
||||
Gets track/episode data and audio stream
|
||||
Args:
|
||||
|
@ -169,20 +190,19 @@ class Session:
|
|||
Returns:
|
||||
Episode object
|
||||
"""
|
||||
return Episode(self.__get_playable(episode_id, Quality.NORMAL), self.api())
|
||||
return Episode(
|
||||
self.__get_playable(EpisodeId.from_base62(episode_id), Quality.NORMAL),
|
||||
self.api(),
|
||||
)
|
||||
|
||||
def api(self) -> ApiClient:
|
||||
def api(self) -> Api:
|
||||
"""Returns API Client"""
|
||||
return self.__api
|
||||
|
||||
def country(self) -> str:
|
||||
"""Returns two letter country code of user's account"""
|
||||
return self.__country
|
||||
def language(self) -> str:
|
||||
"""Returns session language"""
|
||||
return self.__language
|
||||
|
||||
def is_premium(self) -> bool:
|
||||
"""Returns users premium account status"""
|
||||
return self.__session.get_user_attribute("type") == "premium"
|
||||
|
||||
def clone(self) -> Session:
|
||||
"""Creates a copy of the session for use in a parallel thread"""
|
||||
return Session(self.__session, self.__language)
|
||||
return self.get_user_attribute("type") == "premium"
|
||||
|
|
|
@ -7,7 +7,7 @@ from zotify.app import App
|
|||
from zotify.config import CONFIG_PATHS, CONFIG_VALUES
|
||||
from zotify.utils import OptionalOrFalse, SimpleHelpFormatter
|
||||
|
||||
VERSION = "0.9.3"
|
||||
VERSION = "0.9.4"
|
||||
|
||||
|
||||
def main():
|
||||
|
@ -25,7 +25,7 @@ def main():
|
|||
parser.add_argument(
|
||||
"--debug",
|
||||
action="store_true",
|
||||
help="Don't hide tracebacks",
|
||||
help="Display full tracebacks",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--config",
|
||||
|
@ -138,8 +138,9 @@ def main():
|
|||
from traceback import format_exc
|
||||
|
||||
print(format_exc().splitlines()[-1])
|
||||
exit(1)
|
||||
except KeyboardInterrupt:
|
||||
print("goodbye")
|
||||
exit(130)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
|
|
444
zotify/app.py
444
zotify/app.py
|
@ -1,47 +1,33 @@
|
|||
from argparse import Namespace
|
||||
from enum import Enum
|
||||
from pathlib import Path
|
||||
from typing import Any, NamedTuple
|
||||
|
||||
from librespot.metadata import (
|
||||
AlbumId,
|
||||
ArtistId,
|
||||
EpisodeId,
|
||||
PlayableId,
|
||||
PlaylistId,
|
||||
ShowId,
|
||||
TrackId,
|
||||
)
|
||||
from librespot.util import bytes_to_hex
|
||||
from typing import Any
|
||||
|
||||
from zotify import Session
|
||||
from zotify.collections import Album, Artist, Collection, Episode, Playlist, Show, Track
|
||||
from zotify.config import Config
|
||||
from zotify.file import TranscodingError
|
||||
from zotify.loader import Loader
|
||||
from zotify.printer import PrintChannel, Printer
|
||||
from zotify.utils import API_URL, AudioFormat, MetadataEntry, b62_to_hex
|
||||
from zotify.logger import LogChannel, Logger
|
||||
from zotify.utils import (
|
||||
AudioFormat,
|
||||
CollectionType,
|
||||
PlayableType,
|
||||
)
|
||||
|
||||
|
||||
class ParseError(ValueError):
|
||||
...
|
||||
|
||||
|
||||
class PlayableType(Enum):
|
||||
TRACK = "track"
|
||||
EPISODE = "episode"
|
||||
|
||||
|
||||
class PlayableData(NamedTuple):
|
||||
type: PlayableType
|
||||
id: PlayableId
|
||||
library: Path
|
||||
output: str
|
||||
metadata: list[MetadataEntry] = []
|
||||
class ParseError(ValueError): ...
|
||||
|
||||
|
||||
class Selection:
|
||||
def __init__(self, session: Session):
|
||||
self.__session = session
|
||||
self.__items: list[dict[str, Any]] = []
|
||||
self.__print_labels = {
|
||||
"album": ("name", "artists"),
|
||||
"playlist": ("name", "owner"),
|
||||
"track": ("title", "artists", "album"),
|
||||
"show": ("title", "creator"),
|
||||
}
|
||||
|
||||
def search(
|
||||
self,
|
||||
|
@ -57,54 +43,55 @@ class Selection:
|
|||
) -> list[str]:
|
||||
categories = ",".join(category)
|
||||
with Loader("Searching..."):
|
||||
country = self.__session.api().invoke_url("me")["country"]
|
||||
resp = self.__session.api().invoke_url(
|
||||
API_URL + "search",
|
||||
"search",
|
||||
{
|
||||
"q": search_text,
|
||||
"type": categories,
|
||||
"include_external": "audio",
|
||||
"market": self.__session.country(),
|
||||
"market": country,
|
||||
},
|
||||
limit=10,
|
||||
offset=0,
|
||||
)
|
||||
|
||||
count = 0
|
||||
links = []
|
||||
for c in categories.split(","):
|
||||
label = c + "s"
|
||||
if len(resp[label]["items"]) > 0:
|
||||
for cat in categories.split(","):
|
||||
label = cat + "s"
|
||||
items = resp[label]["items"]
|
||||
if len(items) > 0:
|
||||
print(f"\n### {label.capitalize()} ###")
|
||||
for item in resp[label]["items"]:
|
||||
links.append(item)
|
||||
self.__print(count + 1, item)
|
||||
count += 1
|
||||
return self.__get_selection(links)
|
||||
try:
|
||||
self.__print(count, items, *self.__print_labels[cat])
|
||||
except KeyError:
|
||||
self.__print(count, items, "name")
|
||||
count += len(items)
|
||||
self.__items.extend(items)
|
||||
return self.__get_selection()
|
||||
|
||||
def get(self, category: str, name: str = "", content: str = "") -> list[str]:
|
||||
with Loader("Fetching items..."):
|
||||
r = self.__session.api().invoke_url(f"{API_URL}me/{category}", limit=50)
|
||||
r = self.__session.api().invoke_url(f"me/{category}", limit=50)
|
||||
if content != "":
|
||||
r = r[content]
|
||||
resp = r["items"]
|
||||
|
||||
items = []
|
||||
for i in range(len(resp)):
|
||||
try:
|
||||
item = resp[i][name]
|
||||
except KeyError:
|
||||
item = resp[i]
|
||||
items.append(item)
|
||||
self.__items.append(item)
|
||||
self.__print(i + 1, item)
|
||||
return self.__get_selection(items)
|
||||
return self.__get_selection()
|
||||
|
||||
@staticmethod
|
||||
def from_file(file_path: Path) -> list[str]:
|
||||
with open(file_path, "r", encoding="utf-8") as f:
|
||||
return [line.strip() for line in f.readlines()]
|
||||
|
||||
@staticmethod
|
||||
def __get_selection(items: list[dict[str, Any]]) -> list[str]:
|
||||
def __get_selection(self) -> list[str]:
|
||||
print("\nResults to save (eg: 1,2,5 1-3)")
|
||||
selection = ""
|
||||
while len(selection) == 0:
|
||||
|
@ -115,64 +102,40 @@ class Selection:
|
|||
if "-" in i:
|
||||
split = i.split("-")
|
||||
for x in range(int(split[0]), int(split[1]) + 1):
|
||||
ids.append(items[x - 1]["uri"])
|
||||
ids.append(self.__items[x - 1]["uri"])
|
||||
else:
|
||||
ids.append(items[int(i) - 1]["uri"])
|
||||
ids.append(self.__items[int(i) - 1]["uri"])
|
||||
return ids
|
||||
|
||||
def __print(self, i: int, item: dict[str, Any]) -> None:
|
||||
match item["type"]:
|
||||
case "album":
|
||||
self.__print_album(i, item)
|
||||
case "playlist":
|
||||
self.__print_playlist(i, item)
|
||||
case "track":
|
||||
self.__print_track(i, item)
|
||||
case "show":
|
||||
self.__print_show(i, item)
|
||||
case _:
|
||||
print(
|
||||
"{:<2} {:<77}".format(i, self.__fix_string_length(item["name"], 77))
|
||||
def __print(self, count: int, items: list[dict[str, Any]], *args: str) -> None:
|
||||
arg_range = range(len(args))
|
||||
category_str = " " + " ".join("{:<38}" for _ in arg_range)
|
||||
print(category_str.format(*[s.upper() for s in list(args)]))
|
||||
for item in items:
|
||||
count += 1
|
||||
fmt_str = "{:<2} ".format(count) + " ".join("{:<38}" for _ in arg_range)
|
||||
fmt_vals: list[str] = []
|
||||
for arg in args:
|
||||
match arg:
|
||||
case "artists":
|
||||
fmt_vals.append(
|
||||
", ".join([artist["name"] for artist in item["artists"]])
|
||||
)
|
||||
case "owner":
|
||||
fmt_vals.append(item["owner"]["display_name"])
|
||||
case "album":
|
||||
fmt_vals.append(item["album"]["name"])
|
||||
case "creator":
|
||||
fmt_vals.append(item["publisher"])
|
||||
case "title":
|
||||
fmt_vals.append(item["name"])
|
||||
case _:
|
||||
fmt_vals.append(item[arg])
|
||||
print(
|
||||
fmt_str.format(
|
||||
*(self.__fix_string_length(fmt_vals[x], 38) for x in arg_range),
|
||||
)
|
||||
|
||||
def __print_album(self, i: int, item: dict[str, Any]) -> None:
|
||||
artists = ", ".join([artist["name"] for artist in item["artists"]])
|
||||
print(
|
||||
"{:<2} {:<38} {:<38}".format(
|
||||
i,
|
||||
self.__fix_string_length(item["name"], 38),
|
||||
self.__fix_string_length(artists, 38),
|
||||
)
|
||||
)
|
||||
|
||||
def __print_playlist(self, i: int, item: dict[str, Any]) -> None:
|
||||
print(
|
||||
"{:<2} {:<38} {:<38}".format(
|
||||
i,
|
||||
self.__fix_string_length(item["name"], 38),
|
||||
self.__fix_string_length(item["owner"]["display_name"], 38),
|
||||
)
|
||||
)
|
||||
|
||||
def __print_track(self, i: int, item: dict[str, Any]) -> None:
|
||||
artists = ", ".join([artist["name"] for artist in item["artists"]])
|
||||
print(
|
||||
"{:<2} {:<38} {:<38} {:<38}".format(
|
||||
i,
|
||||
self.__fix_string_length(item["name"], 38),
|
||||
self.__fix_string_length(artists, 38),
|
||||
self.__fix_string_length(item["album"]["name"], 38),
|
||||
)
|
||||
)
|
||||
|
||||
def __print_show(self, i: int, item: dict[str, Any]) -> None:
|
||||
print(
|
||||
"{:<2} {:<38} {:<38}".format(
|
||||
i,
|
||||
self.__fix_string_length(item["name"], 38),
|
||||
self.__fix_string_length(item["publisher"], 38),
|
||||
)
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def __fix_string_length(text: str, max_length: int) -> str:
|
||||
|
@ -182,42 +145,48 @@ class Selection:
|
|||
|
||||
|
||||
class App:
|
||||
__playable_list: list[PlayableData] = []
|
||||
|
||||
def __init__(self, args: Namespace):
|
||||
self.__config = Config(args)
|
||||
Printer(self.__config)
|
||||
Logger(self.__config)
|
||||
|
||||
# Check options
|
||||
if self.__config.audio_format == AudioFormat.VORBIS and (
|
||||
self.__config.ffmpeg_args != "" or self.__config.ffmpeg_path != ""
|
||||
):
|
||||
Printer.print(
|
||||
PrintChannel.WARNINGS,
|
||||
Logger.log(
|
||||
LogChannel.WARNINGS,
|
||||
"FFmpeg options will be ignored since no transcoding is required",
|
||||
)
|
||||
|
||||
with Loader("Logging in..."):
|
||||
if (
|
||||
args.username != "" and args.password != ""
|
||||
) or not self.__config.credentials.is_file():
|
||||
self.__session = Session.from_userpass(
|
||||
args.username,
|
||||
args.password,
|
||||
self.__config.credentials,
|
||||
self.__config.language,
|
||||
)
|
||||
else:
|
||||
self.__session = Session.from_file(
|
||||
self.__config.credentials, self.__config.language
|
||||
)
|
||||
# Create session
|
||||
if args.username != "" and args.password != "":
|
||||
self.__session = Session.from_userpass(
|
||||
args.username,
|
||||
args.password,
|
||||
self.__config.credentials,
|
||||
self.__config.language,
|
||||
)
|
||||
elif self.__config.credentials.is_file():
|
||||
self.__session = Session.from_file(
|
||||
self.__config.credentials, self.__config.language
|
||||
)
|
||||
else:
|
||||
self.__session = Session.from_prompt(
|
||||
self.__config.credentials, self.__config.language
|
||||
)
|
||||
|
||||
# Get items to download
|
||||
ids = self.get_selection(args)
|
||||
with Loader("Parsing input..."):
|
||||
try:
|
||||
self.parse(ids)
|
||||
collections = self.parse(ids)
|
||||
except ParseError as e:
|
||||
Printer.print(PrintChannel.ERRORS, str(e))
|
||||
self.download_all()
|
||||
Logger.log(LogChannel.ERRORS, str(e))
|
||||
if len(collections) > 0:
|
||||
self.download_all(collections)
|
||||
else:
|
||||
Logger.log(LogChannel.WARNINGS, "there is nothing to do")
|
||||
exit(0)
|
||||
|
||||
def get_selection(self, args: Namespace) -> list[str]:
|
||||
selection = Selection(self.__session)
|
||||
|
@ -240,17 +209,14 @@ class App:
|
|||
elif args.urls:
|
||||
return args.urls
|
||||
except (FileNotFoundError, ValueError):
|
||||
Printer.print(PrintChannel.WARNINGS, "there is nothing to do")
|
||||
Logger.log(LogChannel.WARNINGS, "there is nothing to do")
|
||||
except KeyboardInterrupt:
|
||||
Printer.print(PrintChannel.WARNINGS, "\nthere is nothing to do")
|
||||
exit()
|
||||
Logger.log(LogChannel.WARNINGS, "\nthere is nothing to do")
|
||||
exit(130)
|
||||
exit(0)
|
||||
|
||||
def parse(self, links: list[str]) -> None:
|
||||
"""
|
||||
Parses list of selected tracks/playlists/shows/etc...
|
||||
Args:
|
||||
links: List of links
|
||||
"""
|
||||
def parse(self, links: list[str]) -> list[Collection]:
|
||||
collections: list[Collection] = []
|
||||
for link in links:
|
||||
link = link.rsplit("?", 1)[0]
|
||||
try:
|
||||
|
@ -262,152 +228,92 @@ class App:
|
|||
|
||||
match id_type:
|
||||
case "album":
|
||||
self.__parse_album(b62_to_hex(_id))
|
||||
collections.append(Album(self.__session, _id))
|
||||
case "artist":
|
||||
self.__parse_artist(b62_to_hex(_id))
|
||||
collections.append(Artist(self.__session, _id))
|
||||
case "show":
|
||||
self.__parse_show(b62_to_hex(_id))
|
||||
collections.append(Show(self.__session, _id))
|
||||
case "track":
|
||||
self.__parse_track(b62_to_hex(_id))
|
||||
collections.append(Track(self.__session, _id))
|
||||
case "episode":
|
||||
self.__parse_episode(b62_to_hex(_id))
|
||||
collections.append(Episode(self.__session, _id))
|
||||
case "playlist":
|
||||
self.__parse_playlist(_id)
|
||||
collections.append(Playlist(self.__session, _id))
|
||||
case _:
|
||||
raise ParseError(f'Unknown content type "{id_type}"')
|
||||
raise ParseError(f'Unsupported content type "{id_type}"')
|
||||
return collections
|
||||
|
||||
def __parse_album(self, hex_id: str) -> None:
|
||||
album = self.__session.api().get_metadata_4_album(AlbumId.from_hex(hex_id))
|
||||
for disc in album.disc:
|
||||
for track in disc.track:
|
||||
self.__playable_list.append(
|
||||
PlayableData(
|
||||
PlayableType.TRACK,
|
||||
TrackId.from_hex(bytes_to_hex(track.gid)),
|
||||
self.__config.music_library,
|
||||
self.__config.output_album,
|
||||
)
|
||||
)
|
||||
|
||||
def __parse_artist(self, hex_id: str) -> None:
|
||||
artist = self.__session.api().get_metadata_4_artist(ArtistId.from_hex(hex_id))
|
||||
for album_group in artist.album_group and artist.single_group:
|
||||
album = self.__session.api().get_metadata_4_album(
|
||||
AlbumId.from_hex(album_group.album[0].gid)
|
||||
)
|
||||
for disc in album.disc:
|
||||
for track in disc.track:
|
||||
self.__playable_list.append(
|
||||
PlayableData(
|
||||
PlayableType.TRACK,
|
||||
TrackId.from_hex(bytes_to_hex(track.gid)),
|
||||
self.__config.music_library,
|
||||
self.__config.output_album,
|
||||
)
|
||||
)
|
||||
|
||||
def __parse_playlist(self, b62_id: str) -> None:
|
||||
playlist = self.__session.api().get_playlist(PlaylistId(b62_id))
|
||||
for item in playlist.contents.items:
|
||||
split = item.uri.split(":")
|
||||
playable_type = PlayableType(split[1])
|
||||
id_map = {PlayableType.TRACK: TrackId, PlayableType.EPISODE: EpisodeId}
|
||||
playable_id = id_map[playable_type].from_base62(split[2])
|
||||
self.__playable_list.append(
|
||||
PlayableData(
|
||||
playable_type,
|
||||
playable_id,
|
||||
self.__config.playlist_library,
|
||||
self.__config.get(f"output_playlist_{playable_type.value}"),
|
||||
)
|
||||
)
|
||||
|
||||
def __parse_show(self, hex_id: str) -> None:
|
||||
show = self.__session.api().get_metadata_4_show(ShowId.from_hex(hex_id))
|
||||
for episode in show.episode:
|
||||
self.__playable_list.append(
|
||||
PlayableData(
|
||||
PlayableType.EPISODE,
|
||||
EpisodeId.from_hex(bytes_to_hex(episode.gid)),
|
||||
self.__config.podcast_library,
|
||||
self.__config.output_podcast,
|
||||
)
|
||||
)
|
||||
|
||||
def __parse_track(self, hex_id: str) -> None:
|
||||
self.__playable_list.append(
|
||||
PlayableData(
|
||||
PlayableType.TRACK,
|
||||
TrackId.from_hex(hex_id),
|
||||
self.__config.music_library,
|
||||
self.__config.output_album,
|
||||
)
|
||||
)
|
||||
|
||||
def __parse_episode(self, hex_id: str) -> None:
|
||||
self.__playable_list.append(
|
||||
PlayableData(
|
||||
PlayableType.EPISODE,
|
||||
EpisodeId.from_hex(hex_id),
|
||||
self.__config.podcast_library,
|
||||
self.__config.output_podcast,
|
||||
)
|
||||
)
|
||||
|
||||
def get_playable_list(self) -> list[PlayableData]:
|
||||
"""Returns list of Playable items"""
|
||||
return self.__playable_list
|
||||
|
||||
def download_all(self) -> None:
|
||||
def download_all(self, collections: list[Collection]) -> None:
|
||||
"""Downloads playable to local file"""
|
||||
for playable in self.__playable_list:
|
||||
self.__download(playable)
|
||||
for collection in collections:
|
||||
for i in range(len(collection.playables)):
|
||||
playable = collection.playables[i]
|
||||
|
||||
def __download(self, playable: PlayableData) -> None:
|
||||
if playable.type == PlayableType.TRACK:
|
||||
with Loader("Fetching track..."):
|
||||
track = self.__session.get_track(
|
||||
playable.id, self.__config.download_quality
|
||||
)
|
||||
elif playable.type == PlayableType.EPISODE:
|
||||
with Loader("Fetching episode..."):
|
||||
track = self.__session.get_episode(playable.id)
|
||||
else:
|
||||
Printer.print(
|
||||
PrintChannel.SKIPS,
|
||||
f'Download Error: Unknown playable content "{playable.type}"',
|
||||
)
|
||||
return
|
||||
|
||||
output = track.create_output(playable.library, playable.output)
|
||||
file = track.write_audio_stream(
|
||||
output,
|
||||
self.__config.chunk_size,
|
||||
)
|
||||
|
||||
if playable.type == PlayableType.TRACK and self.__config.lyrics_file:
|
||||
with Loader("Fetching lyrics..."):
|
||||
try:
|
||||
track.get_lyrics().save(output)
|
||||
except FileNotFoundError as e:
|
||||
Printer.print(PrintChannel.SKIPS, str(e))
|
||||
|
||||
Printer.print(PrintChannel.DOWNLOADS, f"\nDownloaded {track.name}")
|
||||
|
||||
if self.__config.audio_format != AudioFormat.VORBIS:
|
||||
try:
|
||||
with Loader(PrintChannel.PROGRESS, "Converting audio..."):
|
||||
file.transcode(
|
||||
self.__config.audio_format,
|
||||
self.__config.transcode_bitrate,
|
||||
True,
|
||||
self.__config.ffmpeg_path,
|
||||
self.__config.ffmpeg_args.split(),
|
||||
# Get track data
|
||||
if playable.type == PlayableType.TRACK:
|
||||
with Loader("Fetching track..."):
|
||||
track = self.__session.get_track(
|
||||
playable.id, self.__config.download_quality
|
||||
)
|
||||
elif playable.type == PlayableType.EPISODE:
|
||||
with Loader("Fetching episode..."):
|
||||
track = self.__session.get_episode(playable.id)
|
||||
else:
|
||||
Logger.log(
|
||||
LogChannel.SKIPS,
|
||||
f'Download Error: Unknown playable content "{playable.type}"',
|
||||
)
|
||||
except TranscodingError as e:
|
||||
Printer.print(PrintChannel.ERRORS, str(e))
|
||||
return
|
||||
|
||||
if self.__config.save_metadata:
|
||||
with Loader("Writing metadata..."):
|
||||
file.write_metadata(track.metadata)
|
||||
file.write_cover_art(track.get_cover_art(self.__config.artwork_size))
|
||||
# Create download location and generate file name
|
||||
match collection.type():
|
||||
case CollectionType.PLAYLIST:
|
||||
# TODO: add playlist name to track metadata
|
||||
library = self.__config.playlist_library
|
||||
template = (
|
||||
self.__config.output_playlist_track
|
||||
if playable.type == PlayableType.TRACK
|
||||
else self.__config.output_playlist_episode
|
||||
)
|
||||
case CollectionType.SHOW | CollectionType.EPISODE:
|
||||
library = self.__config.podcast_library
|
||||
template = self.__config.output_podcast
|
||||
case _:
|
||||
library = self.__config.music_library
|
||||
template = self.__config.output_album
|
||||
output = track.create_output(
|
||||
library, template, self.__config.replace_existing
|
||||
)
|
||||
|
||||
file = track.write_audio_stream(output)
|
||||
|
||||
# Download lyrics
|
||||
if playable.type == PlayableType.TRACK and self.__config.lyrics_file:
|
||||
with Loader("Fetching lyrics..."):
|
||||
try:
|
||||
track.get_lyrics().save(output)
|
||||
except FileNotFoundError as e:
|
||||
Logger.log(LogChannel.SKIPS, str(e))
|
||||
Logger.log(LogChannel.DOWNLOADS, f"\nDownloaded {track.name}")
|
||||
|
||||
# Transcode audio
|
||||
if self.__config.audio_format != AudioFormat.VORBIS:
|
||||
try:
|
||||
with Loader(LogChannel.PROGRESS, "Converting audio..."):
|
||||
file.transcode(
|
||||
self.__config.audio_format,
|
||||
self.__config.transcode_bitrate,
|
||||
True,
|
||||
self.__config.ffmpeg_path,
|
||||
self.__config.ffmpeg_args.split(),
|
||||
)
|
||||
except TranscodingError as e:
|
||||
Logger.log(LogChannel.ERRORS, str(e))
|
||||
|
||||
# Write metadata
|
||||
if self.__config.save_metadata:
|
||||
with Loader("Writing metadata..."):
|
||||
file.write_metadata(track.metadata)
|
||||
file.write_cover_art(
|
||||
track.get_cover_art(self.__config.artwork_size)
|
||||
)
|
||||
|
|
95
zotify/collections.py
Normal file
95
zotify/collections.py
Normal file
|
@ -0,0 +1,95 @@
|
|||
from librespot.metadata import (
|
||||
AlbumId,
|
||||
ArtistId,
|
||||
PlaylistId,
|
||||
ShowId,
|
||||
)
|
||||
|
||||
from zotify import Session
|
||||
from zotify.utils import CollectionType, PlayableData, PlayableType, bytes_to_base62
|
||||
|
||||
|
||||
class Collection:
|
||||
playables: list[PlayableData] = []
|
||||
|
||||
def type(self) -> CollectionType:
|
||||
return CollectionType(self.__class__.__name__.lower())
|
||||
|
||||
|
||||
class Album(Collection):
|
||||
def __init__(self, session: Session, b62_id: str):
|
||||
album = session.api().get_metadata_4_album(AlbumId.from_base62(b62_id))
|
||||
for disc in album.disc:
|
||||
for track in disc.track:
|
||||
self.playables.append(
|
||||
PlayableData(
|
||||
PlayableType.TRACK,
|
||||
bytes_to_base62(track.gid),
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
class Artist(Collection):
|
||||
def __init__(self, session: Session, b62_id: str):
|
||||
artist = session.api().get_metadata_4_artist(ArtistId.from_base62(b62_id))
|
||||
for album_group in (
|
||||
artist.album_group
|
||||
and artist.single_group
|
||||
and artist.compilation_group
|
||||
and artist.appears_on_group
|
||||
):
|
||||
album = session.api().get_metadata_4_album(
|
||||
AlbumId.from_hex(album_group.album[0].gid)
|
||||
)
|
||||
for disc in album.disc:
|
||||
for track in disc.track:
|
||||
self.playables.append(
|
||||
PlayableData(
|
||||
PlayableType.TRACK,
|
||||
bytes_to_base62(track.gid),
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
class Show(Collection):
|
||||
def __init__(self, session: Session, b62_id: str):
|
||||
show = session.api().get_metadata_4_show(ShowId.from_base62(b62_id))
|
||||
for episode in show.episode:
|
||||
self.playables.append(
|
||||
PlayableData(PlayableType.EPISODE, bytes_to_base62(episode.gid))
|
||||
)
|
||||
|
||||
|
||||
class Playlist(Collection):
|
||||
def __init__(self, session: Session, b62_id: str):
|
||||
playlist = session.api().get_playlist(PlaylistId(b62_id))
|
||||
# self.name = playlist.title
|
||||
for item in playlist.contents.items:
|
||||
split = item.uri.split(":")
|
||||
playable_type = split[1]
|
||||
if playable_type == "track":
|
||||
self.playables.append(
|
||||
PlayableData(
|
||||
PlayableType.TRACK,
|
||||
split[2],
|
||||
)
|
||||
)
|
||||
elif playable_type == "episode":
|
||||
self.playables.append(
|
||||
PlayableData(
|
||||
PlayableType.EPISODE,
|
||||
split[2],
|
||||
)
|
||||
)
|
||||
else:
|
||||
raise ValueError("Unknown playable content", playable_type)
|
||||
|
||||
|
||||
class Track(Collection):
|
||||
def __init__(self, session: Session, b62_id: str):
|
||||
self.playables.append(PlayableData(PlayableType.TRACK, b62_id))
|
||||
|
||||
|
||||
class Episode(Collection):
|
||||
def __init__(self, session: Session, b62_id: str):
|
||||
self.playables.append(PlayableData(PlayableType.EPISODE, b62_id))
|
|
@ -10,7 +10,6 @@ from zotify.utils import AudioFormat, ImageSize, Quality
|
|||
ALL_ARTISTS = "all_artists"
|
||||
ARTWORK_SIZE = "artwork_size"
|
||||
AUDIO_FORMAT = "audio_format"
|
||||
CHUNK_SIZE = "chunk_size"
|
||||
CREATE_PLAYLIST_FILE = "create_playlist_file"
|
||||
CREDENTIALS = "credentials"
|
||||
DOWNLOAD_QUALITY = "download_quality"
|
||||
|
@ -64,8 +63,8 @@ CONFIG_PATHS = {
|
|||
OUTPUT_PATHS = {
|
||||
"album": "{album_artist}/{album}/{track_number}. {artists} - {title}",
|
||||
"podcast": "{podcast}/{episode_number} - {title}",
|
||||
"playlist_track": "{playlist}/{playlist_number}. {artists} - {title}",
|
||||
"playlist_episode": "{playlist}/{playlist_number}. {episode_number} - {title}",
|
||||
"playlist_track": "{playlist}/{artists} - {title}",
|
||||
"playlist_episode": "{playlist}/{episode_number} - {title}",
|
||||
}
|
||||
|
||||
CONFIG_VALUES = {
|
||||
|
@ -222,12 +221,6 @@ CONFIG_VALUES = {
|
|||
"args": ["--skip-duplicates"],
|
||||
"help": "Skip downloading existing track to different album",
|
||||
},
|
||||
CHUNK_SIZE: {
|
||||
"default": 16384,
|
||||
"type": int,
|
||||
"args": ["--chunk-size"],
|
||||
"help": "Number of bytes read at a time during download",
|
||||
},
|
||||
PRINT_DOWNLOADS: {
|
||||
"default": False,
|
||||
"type": bool,
|
||||
|
@ -265,7 +258,6 @@ class Config:
|
|||
__config_file: Path | None
|
||||
artwork_size: ImageSize
|
||||
audio_format: AudioFormat
|
||||
chunk_size: int
|
||||
credentials: Path
|
||||
download_quality: Quality
|
||||
ffmpeg_args: str
|
||||
|
@ -274,13 +266,13 @@ class Config:
|
|||
language: str
|
||||
lyrics_file: bool
|
||||
output_album: str
|
||||
output_liked: str
|
||||
output_podcast: str
|
||||
output_playlist_track: str
|
||||
output_playlist_episode: str
|
||||
playlist_library: Path
|
||||
podcast_library: Path
|
||||
print_progress: bool
|
||||
replace_existing: bool
|
||||
save_metadata: bool
|
||||
transcode_bitrate: int
|
||||
|
||||
|
@ -323,14 +315,14 @@ class Config:
|
|||
|
||||
# "library" arg overrides all *_library options
|
||||
if args.library:
|
||||
self.music_library = args.library
|
||||
self.playlist_library = args.library
|
||||
self.podcast_library = args.library
|
||||
print("args.library")
|
||||
self.music_library = Path(args.library).expanduser().resolve()
|
||||
self.playlist_library = Path(args.library).expanduser().resolve()
|
||||
self.podcast_library = Path(args.library).expanduser().resolve()
|
||||
|
||||
# "output" arg overrides all output_* options
|
||||
if args.output:
|
||||
self.output_album = args.output
|
||||
self.output_liked = args.output
|
||||
self.output_podcast = args.output
|
||||
self.output_playlist_track = args.output
|
||||
self.output_playlist_episode = args.output
|
||||
|
@ -338,10 +330,10 @@ class Config:
|
|||
@staticmethod
|
||||
def __parse_arg_value(key: str, value: Any) -> Any:
|
||||
config_type = CONFIG_VALUES[key]["type"]
|
||||
if type(value) == config_type:
|
||||
if type(value) is config_type:
|
||||
return value
|
||||
elif config_type == Path:
|
||||
return Path(value).expanduser()
|
||||
return Path(value).expanduser().resolve()
|
||||
elif config_type == AudioFormat:
|
||||
return AudioFormat[value.upper()]
|
||||
elif config_type == ImageSize.from_string:
|
||||
|
|
|
@ -8,8 +8,7 @@ from mutagen.oggvorbis import OggVorbisHeaderError
|
|||
from zotify.utils import AudioFormat, MetadataEntry
|
||||
|
||||
|
||||
class TranscodingError(RuntimeError):
|
||||
...
|
||||
class TranscodingError(RuntimeError): ...
|
||||
|
||||
|
||||
class LocalFile:
|
||||
|
|
|
@ -8,7 +8,7 @@ from sys import platform
|
|||
from threading import Thread
|
||||
from time import sleep
|
||||
|
||||
from zotify.printer import Printer
|
||||
from zotify.logger import Logger
|
||||
|
||||
|
||||
class Loader:
|
||||
|
@ -50,7 +50,7 @@ class Loader:
|
|||
for c in cycle(self.steps):
|
||||
if self.done:
|
||||
break
|
||||
Printer.print_loader(f"\r {c} {self.desc} ")
|
||||
Logger.print_loader(f"\r {c} {self.desc} ")
|
||||
sleep(self.timeout)
|
||||
|
||||
def __enter__(self) -> None:
|
||||
|
@ -59,10 +59,10 @@ class Loader:
|
|||
def stop(self) -> None:
|
||||
self.done = True
|
||||
cols = get_terminal_size((80, 20)).columns
|
||||
Printer.print_loader("\r" + " " * cols)
|
||||
Logger.print_loader("\r" + " " * cols)
|
||||
|
||||
if self.end != "":
|
||||
Printer.print_loader(f"\r{self.end}")
|
||||
Logger.print_loader(f"\r{self.end}")
|
||||
|
||||
def __exit__(self, exc_type, exc_value, tb) -> None:
|
||||
# handle exceptions with those variables ^
|
||||
|
|
|
@ -13,7 +13,7 @@ from zotify.config import (
|
|||
)
|
||||
|
||||
|
||||
class PrintChannel(Enum):
|
||||
class LogChannel(Enum):
|
||||
SKIPS = PRINT_SKIPS
|
||||
PROGRESS = PRINT_PROGRESS
|
||||
ERRORS = PRINT_ERRORS
|
||||
|
@ -21,7 +21,7 @@ class PrintChannel(Enum):
|
|||
DOWNLOADS = PRINT_DOWNLOADS
|
||||
|
||||
|
||||
class Printer:
|
||||
class Logger:
|
||||
__config: Config
|
||||
|
||||
@classmethod
|
||||
|
@ -29,15 +29,15 @@ class Printer:
|
|||
cls.__config = config
|
||||
|
||||
@classmethod
|
||||
def print(cls, channel: PrintChannel, msg: str) -> None:
|
||||
def log(cls, channel: LogChannel, msg: str) -> None:
|
||||
"""
|
||||
Prints a message to console if the print channel is enabled
|
||||
Args:
|
||||
channel: PrintChannel to print to
|
||||
msg: Message to print
|
||||
channel: LogChannel to print to
|
||||
msg: Message to log
|
||||
"""
|
||||
if cls.__config.get(channel.value):
|
||||
if channel == PrintChannel.ERRORS:
|
||||
if channel == LogChannel.ERRORS:
|
||||
print(msg, file=stderr)
|
||||
else:
|
||||
print(msg)
|
||||
|
@ -76,7 +76,7 @@ class Printer:
|
|||
"""
|
||||
Prints animated loading symbol
|
||||
Args:
|
||||
msg: Message to print
|
||||
msg: Message to display
|
||||
"""
|
||||
if cls.__config.print_progress:
|
||||
print(msg, flush=True, end="")
|
|
@ -3,37 +3,40 @@ from pathlib import Path
|
|||
from typing import Any
|
||||
|
||||
from librespot.core import PlayableContentFeeder
|
||||
from librespot.metadata import AlbumId
|
||||
from librespot.structure import GeneralAudioStream
|
||||
from librespot.util import bytes_to_hex
|
||||
from requests import get
|
||||
|
||||
from zotify.file import LocalFile
|
||||
from zotify.printer import Printer
|
||||
from zotify.logger import Logger
|
||||
from zotify.utils import (
|
||||
IMG_URL,
|
||||
LYRICS_URL,
|
||||
AudioFormat,
|
||||
ImageSize,
|
||||
MetadataEntry,
|
||||
PlayableType,
|
||||
bytes_to_base62,
|
||||
fix_filename,
|
||||
)
|
||||
|
||||
IMG_URL = "https://i.s" + "cdn.co/image/"
|
||||
LYRICS_URL = "https://sp" + "client.wg.sp" + "otify.com/color-lyrics/v2/track/"
|
||||
|
||||
|
||||
class Lyrics:
|
||||
def __init__(self, lyrics: dict, **kwargs):
|
||||
self.lines = []
|
||||
self.sync_type = lyrics["syncType"]
|
||||
self.__lines = []
|
||||
self.__sync_type = lyrics["syncType"]
|
||||
for line in lyrics["lines"]:
|
||||
self.lines.append(line["words"] + "\n")
|
||||
if self.sync_type == "line_synced":
|
||||
self.lines_synced = []
|
||||
self.__lines.append(line["words"] + "\n")
|
||||
if self.__sync_type == "line_synced":
|
||||
self.__lines_synced = []
|
||||
for line in lyrics["lines"]:
|
||||
timestamp = int(line["start_time_ms"])
|
||||
ts_minutes = str(floor(timestamp / 60000)).zfill(2)
|
||||
ts_seconds = str(floor((timestamp % 60000) / 1000)).zfill(2)
|
||||
ts_millis = str(floor(timestamp % 1000))[:2].zfill(2)
|
||||
self.lines_synced.append(
|
||||
self.__lines_synced.append(
|
||||
f"[{ts_minutes}:{ts_seconds}.{ts_millis}]{line.words}\n"
|
||||
)
|
||||
|
||||
|
@ -44,21 +47,24 @@ class Lyrics:
|
|||
location: path to target lyrics file
|
||||
prefer_synced: Use line synced lyrics if available
|
||||
"""
|
||||
if self.sync_type == "line_synced" and prefer_synced:
|
||||
if self.__sync_type == "line_synced" and prefer_synced:
|
||||
with open(f"{path}.lrc", "w+", encoding="utf-8") as f:
|
||||
f.writelines(self.lines_synced)
|
||||
f.writelines(self.__lines_synced)
|
||||
else:
|
||||
with open(f"{path}.txt", "w+", encoding="utf-8") as f:
|
||||
f.writelines(self.lines[:-1])
|
||||
f.writelines(self.__lines[:-1])
|
||||
|
||||
|
||||
class Playable:
|
||||
cover_images: list[Any]
|
||||
input_stream: GeneralAudioStream
|
||||
metadata: list[MetadataEntry]
|
||||
name: str
|
||||
input_stream: GeneralAudioStream
|
||||
type: PlayableType
|
||||
|
||||
def create_output(self, library: Path, output: str, replace: bool = False) -> Path:
|
||||
def create_output(
|
||||
self, library: Path = Path("./"), output: str = "{title}", replace: bool = False
|
||||
) -> Path:
|
||||
"""
|
||||
Creates save directory for the output file
|
||||
Args:
|
||||
|
@ -68,9 +74,11 @@ class Playable:
|
|||
Returns:
|
||||
File path for the track
|
||||
"""
|
||||
for m in self.metadata:
|
||||
if m.output is not None:
|
||||
output = output.replace("{" + m.name + "}", fix_filename(m.output))
|
||||
for meta in self.metadata:
|
||||
if meta.string is not None:
|
||||
output = output.replace(
|
||||
"{" + meta.name + "}", fix_filename(meta.string)
|
||||
)
|
||||
file_path = library.joinpath(output).expanduser()
|
||||
if file_path.exists() and not replace:
|
||||
raise FileExistsError("File already downloaded")
|
||||
|
@ -81,18 +89,16 @@ class Playable:
|
|||
def write_audio_stream(
|
||||
self,
|
||||
output: Path,
|
||||
chunk_size: int = 128 * 1024,
|
||||
) -> LocalFile:
|
||||
"""
|
||||
Writes audio stream to file
|
||||
Args:
|
||||
output: File path of saved audio stream
|
||||
chunk_size: maximum number of bytes to read at a time
|
||||
Returns:
|
||||
LocalFile object
|
||||
"""
|
||||
file = f"{output}.ogg"
|
||||
with open(file, "wb") as f, Printer.progress(
|
||||
with open(file, "wb") as f, Logger.progress(
|
||||
desc=self.name,
|
||||
total=self.input_stream.size,
|
||||
unit="B",
|
||||
|
@ -103,7 +109,7 @@ class Playable:
|
|||
) as p_bar:
|
||||
chunk = None
|
||||
while chunk != b"":
|
||||
chunk = self.input_stream.stream().read(chunk_size)
|
||||
chunk = self.input_stream.stream().read(1024)
|
||||
p_bar.update(f.write(chunk))
|
||||
return LocalFile(Path(file), AudioFormat.VORBIS)
|
||||
|
||||
|
@ -121,8 +127,6 @@ class Playable:
|
|||
|
||||
|
||||
class Track(PlayableContentFeeder.LoadedStream, Playable):
|
||||
lyrics: Lyrics
|
||||
|
||||
def __init__(self, track: PlayableContentFeeder.LoadedStream, api):
|
||||
super(Track, self).__init__(
|
||||
track.track,
|
||||
|
@ -131,8 +135,10 @@ class Track(PlayableContentFeeder.LoadedStream, Playable):
|
|||
track.metrics,
|
||||
)
|
||||
self.__api = api
|
||||
self.__lyrics: Lyrics
|
||||
self.cover_images = self.album.cover_group.image
|
||||
self.metadata = self.__default_metadata()
|
||||
self.type = PlayableType.TRACK
|
||||
|
||||
def __getattr__(self, name):
|
||||
try:
|
||||
|
@ -142,6 +148,10 @@ class Track(PlayableContentFeeder.LoadedStream, Playable):
|
|||
|
||||
def __default_metadata(self) -> list[MetadataEntry]:
|
||||
date = self.album.date
|
||||
if not hasattr(self.album, "genre"):
|
||||
self.track.album = self.__api().get_metadata_4_album(
|
||||
AlbumId.from_hex(bytes_to_hex(self.album.gid))
|
||||
)
|
||||
return [
|
||||
MetadataEntry("album", self.album.name),
|
||||
MetadataEntry("album_artist", [a.name for a in self.album.artist]),
|
||||
|
@ -155,6 +165,7 @@ class Track(PlayableContentFeeder.LoadedStream, Playable):
|
|||
MetadataEntry("popularity", int(self.popularity * 255) / 100),
|
||||
MetadataEntry("track_number", self.number, str(self.number).zfill(2)),
|
||||
MetadataEntry("title", self.name),
|
||||
MetadataEntry("year", date.year),
|
||||
MetadataEntry(
|
||||
"replaygain_track_gain", self.normalization_data.track_gain_db, ""
|
||||
),
|
||||
|
@ -169,21 +180,21 @@ class Track(PlayableContentFeeder.LoadedStream, Playable):
|
|||
),
|
||||
]
|
||||
|
||||
def get_lyrics(self) -> Lyrics:
|
||||
def lyrics(self) -> Lyrics:
|
||||
"""Returns track lyrics if available"""
|
||||
if not self.track.has_lyrics:
|
||||
raise FileNotFoundError(
|
||||
f"No lyrics available for {self.track.artist[0].name} - {self.track.name}"
|
||||
)
|
||||
try:
|
||||
return self.lyrics
|
||||
return self.__lyrics
|
||||
except AttributeError:
|
||||
self.lyrics = Lyrics(
|
||||
self.__lyrics = Lyrics(
|
||||
self.__api.invoke_url(LYRICS_URL + bytes_to_base62(self.track.gid))[
|
||||
"lyrics"
|
||||
]
|
||||
)
|
||||
return self.lyrics
|
||||
return self.__lyrics
|
||||
|
||||
|
||||
class Episode(PlayableContentFeeder.LoadedStream, Playable):
|
||||
|
@ -197,6 +208,7 @@ class Episode(PlayableContentFeeder.LoadedStream, Playable):
|
|||
self.__api = api
|
||||
self.cover_images = self.episode.cover_image.image
|
||||
self.metadata = self.__default_metadata()
|
||||
self.type = PlayableType.EPISODE
|
||||
|
||||
def __getattr__(self, name):
|
||||
try:
|
||||
|
@ -216,23 +228,21 @@ class Episode(PlayableContentFeeder.LoadedStream, Playable):
|
|||
MetadataEntry("title", self.name),
|
||||
]
|
||||
|
||||
def write_audio_stream(
|
||||
self, output: Path, chunk_size: int = 128 * 1024
|
||||
) -> LocalFile:
|
||||
def write_audio_stream(self, output: Path) -> LocalFile:
|
||||
"""
|
||||
Writes audio stream to file
|
||||
Writes audio stream to file.
|
||||
Uses external source if available for faster download.
|
||||
Args:
|
||||
output: File path of saved audio stream
|
||||
chunk_size: maximum number of bytes to read at a time
|
||||
Returns:
|
||||
LocalFile object
|
||||
"""
|
||||
if not bool(self.external_url):
|
||||
return super().write_audio_stream(output, chunk_size)
|
||||
return super().write_audio_stream(output)
|
||||
file = f"{output}.{self.external_url.rsplit('.', 1)[-1]}"
|
||||
with get(self.external_url, stream=True) as r, open(
|
||||
file, "wb"
|
||||
) as f, Printer.progress(
|
||||
) as f, Logger.progress(
|
||||
desc=self.name,
|
||||
total=self.input_stream.size,
|
||||
unit="B",
|
||||
|
@ -241,6 +251,6 @@ class Episode(PlayableContentFeeder.LoadedStream, Playable):
|
|||
position=0,
|
||||
leave=False,
|
||||
) as p_bar:
|
||||
for chunk in r.iter_content(chunk_size=chunk_size):
|
||||
for chunk in r.iter_content(chunk_size=1024):
|
||||
p_bar.update(f.write(chunk))
|
||||
return LocalFile(Path(file))
|
||||
|
|
|
@ -7,12 +7,8 @@ from sys import stderr
|
|||
from typing import Any, NamedTuple
|
||||
|
||||
from librespot.audio.decoders import AudioQuality
|
||||
from librespot.util import Base62, bytes_to_hex
|
||||
from requests import get
|
||||
from librespot.util import Base62
|
||||
|
||||
API_URL = "https://api.sp" + "otify.com/v1/"
|
||||
IMG_URL = "https://i.s" + "cdn.co/image/"
|
||||
LYRICS_URL = "https://sp" + "client.wg.sp" + "otify.com/color-lyrics/v2/track/"
|
||||
BASE62 = Base62.create_instance_with_inverted_character_set()
|
||||
|
||||
|
||||
|
@ -74,30 +70,47 @@ class ImageSize(IntEnum):
|
|||
class MetadataEntry:
|
||||
name: str
|
||||
value: Any
|
||||
output: str
|
||||
string: str
|
||||
|
||||
def __init__(self, name: str, value: Any, output_value: str | None = None):
|
||||
def __init__(self, name: str, value: Any, string_value: str | None = None):
|
||||
"""
|
||||
Holds metadata entries
|
||||
args:
|
||||
name: name of metadata key
|
||||
value: Value to use in metadata tags
|
||||
output_value: Value when used in output formatting, if none is provided
|
||||
string_value: Value when used in output formatting, if none is provided
|
||||
will use value from previous argument.
|
||||
"""
|
||||
self.name = name
|
||||
|
||||
if type(value) == list:
|
||||
if isinstance(value, tuple):
|
||||
value = "\0".join(value)
|
||||
self.value = value
|
||||
|
||||
if output_value is None:
|
||||
output_value = self.value
|
||||
elif output_value == "":
|
||||
output_value = None
|
||||
if type(output_value) == list:
|
||||
output_value = ", ".join(output_value)
|
||||
self.output = str(output_value)
|
||||
if string_value is None:
|
||||
string_value = self.value
|
||||
if isinstance(string_value, list):
|
||||
string_value = ", ".join(string_value)
|
||||
self.string = str(string_value)
|
||||
|
||||
|
||||
class CollectionType(Enum):
|
||||
ALBUM = "album"
|
||||
ARTIST = "artist"
|
||||
SHOW = "show"
|
||||
PLAYLIST = "playlist"
|
||||
TRACK = "track"
|
||||
EPISODE = "episode"
|
||||
|
||||
|
||||
class PlayableType(Enum):
|
||||
TRACK = "track"
|
||||
EPISODE = "episode"
|
||||
|
||||
|
||||
class PlayableData(NamedTuple):
|
||||
type: PlayableType
|
||||
id: str
|
||||
|
||||
|
||||
class SimpleHelpFormatter(HelpFormatter):
|
||||
|
@ -147,7 +160,14 @@ class OptionalOrFalse(Action):
|
|||
setattr(
|
||||
namespace,
|
||||
self.dest,
|
||||
True if not option_string.startswith("--no-") else False,
|
||||
(
|
||||
True
|
||||
if not (
|
||||
option_string.startswith("--no-")
|
||||
or option_string.startswith("--dont-")
|
||||
)
|
||||
else False
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
|
@ -172,29 +192,12 @@ def fix_filename(filename: str, substitute: str = "_", platform: str = PLATFORM)
|
|||
return sub(regex, substitute, str(filename), flags=IGNORECASE)
|
||||
|
||||
|
||||
def download_cover_art(images: list, size: ImageSize) -> bytes:
|
||||
"""
|
||||
Returns image data of cover art
|
||||
Args:
|
||||
images: list of retrievable images
|
||||
size: Desired size in pixels of cover art, can be 640, 300, or 64
|
||||
Returns:
|
||||
Image data of cover art
|
||||
"""
|
||||
return get(images[size.value]["url"]).content
|
||||
|
||||
|
||||
def str_to_bool(value: str) -> bool:
|
||||
if value.lower() in ["yes", "y", "true"]:
|
||||
return True
|
||||
if value.lower() in ["no", "n", "false"]:
|
||||
return False
|
||||
raise TypeError("Not a boolean: " + value)
|
||||
|
||||
|
||||
def bytes_to_base62(id: bytes) -> str:
|
||||
"""
|
||||
Converts bytes to base62
|
||||
Args:
|
||||
id: bytes
|
||||
Returns:
|
||||
base62
|
||||
"""
|
||||
return BASE62.encode(id, 22).decode()
|
||||
|
||||
|
||||
def b62_to_hex(base62: str) -> str:
|
||||
return bytes_to_hex(BASE62.decode(base62.encode(), 16))
|
||||
|
|
Loading…
Reference in a new issue