This commit is contained in:
Zotify 2024-02-17 17:59:23 +13:00
parent a10b32b5b7
commit 360e342bc2
18 changed files with 923 additions and 463 deletions

View file

@ -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`

View file

@ -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
View 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
View 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": {}
}

View file

@ -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

Binary file not shown.

After

Width:  |  Height:  |  Size: 110 KiB

View file

@ -2,5 +2,6 @@ black
flake8
mypy
pre-commit
types-protobuf
types-requests
wheel

View file

@ -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

View file

@ -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"

View file

@ -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__":

View file

@ -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
View 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))

View file

@ -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:

View file

@ -8,8 +8,7 @@ from mutagen.oggvorbis import OggVorbisHeaderError
from zotify.utils import AudioFormat, MetadataEntry
class TranscodingError(RuntimeError):
...
class TranscodingError(RuntimeError): ...
class LocalFile:

View file

@ -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 ^

View file

@ -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="")

View file

@ -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))

View 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))