diff --git a/CHANGELOG.md b/CHANGELOG.md index 788a032..b1830b7 100644 --- a/CHANGELOG.md +++ b/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` diff --git a/LICENCE b/LICENCE index d3ba069..c012b87 100644 --- a/LICENCE +++ b/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 diff --git a/Pipfile b/Pipfile new file mode 100644 index 0000000..2fc6f0d --- /dev/null +++ b/Pipfile @@ -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" diff --git a/Pipfile.lock b/Pipfile.lock new file mode 100644 index 0000000..4eb010d --- /dev/null +++ b/Pipfile.lock @@ -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": {} +} diff --git a/README.md b/README.md index 923e565..a50d527 100644 --- a/README.md +++ b/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 ZSp‌otify. -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
All configuration options -| 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 Sp‌otify 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. diff --git a/assets/banner.png b/assets/banner.png new file mode 100644 index 0000000..c1d63db Binary files /dev/null and b/assets/banner.png differ diff --git a/requirements_dev.txt b/requirements_dev.txt index 624c4eb..7caa5e4 100644 --- a/requirements_dev.txt +++ b/requirements_dev.txt @@ -2,5 +2,6 @@ black flake8 mypy pre-commit +types-protobuf types-requests wheel diff --git a/setup.cfg b/setup.cfg index 8db7ba7..94867d4 100644 --- a/setup.cfg +++ b/setup.cfg @@ -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 diff --git a/zotify/__init__.py b/zotify/__init__.py index 981d092..01148a3 100644 --- a/zotify/__init__.py +++ b/zotify/__init__.py @@ -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" diff --git a/zotify/__main__.py b/zotify/__main__.py index adbb088..6250003 100644 --- a/zotify/__main__.py +++ b/zotify/__main__.py @@ -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__": diff --git a/zotify/app.py b/zotify/app.py index e3569e0..691dc91 100644 --- a/zotify/app.py +++ b/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) + ) diff --git a/zotify/collections.py b/zotify/collections.py new file mode 100644 index 0000000..d43a3ed --- /dev/null +++ b/zotify/collections.py @@ -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)) diff --git a/zotify/config.py b/zotify/config.py index c2d1a68..b6dcf53 100644 --- a/zotify/config.py +++ b/zotify/config.py @@ -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: diff --git a/zotify/file.py b/zotify/file.py index 4cf1bfc..960f376 100644 --- a/zotify/file.py +++ b/zotify/file.py @@ -8,8 +8,7 @@ from mutagen.oggvorbis import OggVorbisHeaderError from zotify.utils import AudioFormat, MetadataEntry -class TranscodingError(RuntimeError): - ... +class TranscodingError(RuntimeError): ... class LocalFile: diff --git a/zotify/loader.py b/zotify/loader.py index 9eb3885..364a147 100644 --- a/zotify/loader.py +++ b/zotify/loader.py @@ -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 ^ diff --git a/zotify/printer.py b/zotify/logger.py similarity index 85% rename from zotify/printer.py rename to zotify/logger.py index 901e1ff..46c9112 100644 --- a/zotify/printer.py +++ b/zotify/logger.py @@ -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="") diff --git a/zotify/playable.py b/zotify/playable.py index dd312db..e2da87a 100644 --- a/zotify/playable.py +++ b/zotify/playable.py @@ -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)) diff --git a/zotify/utils.py b/zotify/utils.py index 01d5236..62dfc22 100644 --- a/zotify/utils.py +++ b/zotify/utils.py @@ -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))