Ver código fonte

Initial mirror from https://github.com/Evolution0/bandcamp-dl.git

This repository was automatically mirrored.
mitch donaberger 3 meses atrás
commit
5a72c2755f

+ 39 - 0
.github/CONTRIBUTING.rst

@@ -0,0 +1,39 @@
+How To Contribute
+=================
+
+First off, thank you for considering contributing to ``bandcamp-dl``!
+It's people like *you* who make it is such a great tool for everyone.
+
+Here are a few guidelines to get you started (but don't be afraid to
+open half-finished PRs and ask questions if something is unclear!):
+
+
+Workflow
+--------
+
+- No contribution is too small!
+  Please submit as many fixes for typos and grammar bloopers as you can!
+- Try to limit each pull request to *one* change only.
+- Once you've addressed review feedback, make sure to bump the pull 
+  request with a short note.
+
+
+Code
+----
+
+- Try to adhere to PEP8 as best you can.
+
+  - allowed line code length for this project is up to 99 chars
+  - documentation line length is up to 72 chars
+
+- Annotate functions
+- Specify return types
+- Add docstrings
+
+*****
+
+Again, this list is mainly to help you to get started by codifying 
+tribal knowledge and expectations. If something is unclear, feel free
+to ask for help!
+
+Thank you for considering contributing to ``bandcamp-dl``!

+ 12 - 0
.github/ISSUE_TEMPLATE.rst

@@ -0,0 +1,12 @@
+**Python version:**  
+
+**Bandcamp-dl version:**
+
+**Bancamp-dl options:**  
+
+**url:**  
+
+**options:**  
+
+**Describe the issue:**
+-------------------------

+ 36 - 0
.github/ISSUE_TEMPLATE/bug_report.md

@@ -0,0 +1,36 @@
+---
+name: Bug report
+about: Create a report to help us improve
+title: ''
+labels: bug
+assignees: Evolution0
+
+---
+
+**Describe the bug**
+A clear and concise description of what the bug is.
+
+**To Reproduce**
+Command to reproduce the behavior:
+`COMMAND_HERE`
+```
+URL or List of URLs HERE
+```
+
+**Expected behavior**
+A clear and concise description of what you expected to happen.
+
+**Logs**
+Most if not always you will get some kind of output explaining the issue, post it:
+```
+HERE
+``
+If possible after running the command with the --debug option.
+
+**Desktop (please complete the following information):**
+ - OS: [e.g. Windows, Ubuntu, Mac]
+ - Version [e.g. Windows 10 20H2, Ubuntu 20.04, MacOS 11 (Big Sur)]
+ - Python Version [e.g. 2.7, 3.9]
+
+**Additional context**
+Add any other context about the problem here.

+ 20 - 0
.github/ISSUE_TEMPLATE/feature_request.md

@@ -0,0 +1,20 @@
+---
+name: Feature request
+about: Suggest an idea for this project
+title: ''
+labels: enhancement
+assignees: Evolution0
+
+---
+
+**Is your feature request related to a problem? Please describe.**
+A clear and concise description of what the problem is. Ex. I'm always frustrated when [...]
+
+**Describe the solution you'd like**
+A clear and concise description of what you want to happen.
+
+**Describe alternatives you've considered**
+A clear and concise description of any alternative solutions or features you've considered.
+
+**Additional context**
+Add any other context or screenshots about the feature request here.

+ 31 - 0
.github/workflows/publish-to-test-pypi.yml

@@ -0,0 +1,31 @@
+name: Build and publish bandcamp-downloader to TestPyPI
+
+on:
+  push:
+    branches:
+      - master
+
+jobs:
+  build-and-publish:
+    runs-on: ubuntu-latest
+    steps:
+    - name: Checkout
+      uses: actions/checkout@v2
+    - name: Set up Python 3.10
+      uses: actions/setup-python@v2
+      with:
+        python-version: "3.10"
+    - name: Install dependencies
+      run: python -m pip install -U setuptools wheel build
+    - name: Build a binary wheel and a source tarball
+      run: python -m build .
+    - name: Publish distribution to TestPyPI
+      uses: pypa/gh-action-pypi-publish@master
+      with:
+        password: ${{ secrets.TEST_PYPI_API_TOKEN }}
+        repository_url: https://test.pypi.org/legacy/
+    #- name: Publish distribution to PyPI
+    #  if: startsWith(github.ref, 'refs/tags')
+    #  uses: pypa/gh-action-pypi-publish@master
+    #  with:
+    #    password: ${{ secrets.PYPI_API_TOKEN }}

+ 51 - 0
.gitignore

@@ -0,0 +1,51 @@
+# custom
+.cache
+
+*.py[cod]
+
+# C extensions
+*.so
+
+# Packages
+*.egg
+*.egg-info
+dist
+build
+eggs
+parts
+bin
+var
+sdist
+develop-eggs
+.installed.cfg
+lib
+lib64
+files/
+*.mp3
+**/*.mp3
+
+# Installer logs
+pip-log.txt
+
+# Unit test / coverage reports
+.coverage
+.tox
+nosetests.xml
+
+# Translations
+*.mo
+
+# Mr Developer
+.mr.developer.cfg
+.project
+.pydevproject
+*.iml
+*.xml
+bandcamp_dl/asyncdownloader.py
+*.log
+.flake8
+.local.vimrc
+
+venv/
+
+*.patch

+ 10 - 0
.pypirc

@@ -0,0 +1,10 @@
+[distutils]
+index-servers =
+    pypi
+    testpypi
+
+[pypi]
+repository = https://upload.pypi.org/legacy/
+
+[testpypi]
+repository = https://test.pypi.org/legacy/

+ 14 - 0
AUTHORS.rst

@@ -0,0 +1,14 @@
+Credits
+=========
+
+Primary:
+--------
+- `Iheanyi Ekechukwu <https://github.com/iheanyi>`_ (Creator)
+- `Anthony Forsberg <https://github.com/Evolution0>`_ (Lead Maintainer/"Owner")
+- `Contributors Graph <https://github.com/iheanyi/bandcamp-dl/graphs/contributors>`_
+
+
+Third-party:
+------------
+- `AUR Package (bandcamp-dl-git) <https://aur.archlinux.org/packages/bandcamp-dl-git>`_
+- `Homebrew Formulae (bandcamp-dl) <https://formulae.brew.sh/formula/bandcamp-dl>`_

+ 51 - 0
CHANGELOG.rst

@@ -0,0 +1,51 @@
+Changelog:
+==========
+
+Version 0.0.5
+-------------
+- [Enhancement] ID3 Data Windows Explorer Compatibility. (Courtesy of
+  `Bendito999 <https://github.com/Bendito999>`_)
+- [Enhancement] Will now use lxml if available, it is not a requirement.
+- [Bugfix] ``--base-dir`` will now create the specified directory rather
+  than fail.
+- [Enhancement] If a file is incomplete it will be redownloaded on next
+  run.
+- [Bugfix] Fixed
+  `#62 <https://github.com/iheanyi/bandcamp-dl/issues/62>`_.
+- [Dependency] Wgetter is no longer required.
+- [Dependency] Latest versions of dependencies now used.
+
+Version 0.0.6
+-------------
+- [Enhancement] Added the option to skip the downloading of album art.
+- [Enhancement] Individual track downloads work now.
+- [Bugfix] Fixed imports, now working when installed via pip.
+- [Note] Last version to officially support Python 2.7.x
+- [Bugfix] Fixed an encoding issue with accented characters in the
+  filepath. (Thanks `oaubert <https://github.com/oaubert>`_)
+
+Version 0.0.7
+-------------
+- [Enhancement] Will now resume if it finds a valid ``not.finished``
+  file.
+- [Enhancement] Interrupting downloads is safe, they will resume on next
+  run.
+- [Enhancement] Interrupting encoding is safe, it will finish on next
+  run.
+- [Enhancement] CLI output is now much neater.
+- [Bugfix] Partial albums (some previews disabled) will now download
+  properly.
+- [Dependency] Slimit is no longer required.
+- [Dependency] Ply is no longer required.
+- [Dependency] demjson is now required.
+- [Bugfix] Downloading singles is now fixed.
+- [Bugfix] Monkey-patched Requests to fix compatability with Python
+  versions before 3.6.
+- [Enhancement] Added a --group option to insert a group tag (iTunes)
+
+Version 0.0.8
+-------------
+- [Enhancement] --embed-art option to forcibly embed album art (if
+  available)
+- [Enhancement] --track option for downloading individual tracks and
+  singles.

+ 81 - 0
CODE_OF_CONDUCT.md

@@ -0,0 +1,81 @@
+# Contributor Covenant Code of Conduct
+
+## Our Pledge
+
+In the interest of fostering an open and welcoming environment, we as
+contributors and maintainers pledge to making participation in our
+project and our community a harassment-free experience for everyone,
+regardless of age, body size, disability, ethnicity, sex
+characteristics, gender identity and expression, level of experience,
+education, socio-economic status, nationality, personal appearance,
+race, religion, or sexual identity and orientation.
+
+## Our Standards
+
+Examples of behavior that contributes to creating a positive environment
+include:
+
+* Using welcoming and inclusive language
+* Being respectful of differing viewpoints and experiences
+* Gracefully accepting constructive criticism
+* Focusing on what is best for the community
+* Showing empathy towards other community members
+
+Examples of unacceptable behavior by participants include:
+
+* The use of sexualized language or imagery and unwelcome sexual
+  attention or advances
+* Trolling, insulting/derogatory comments, and personal or political
+  attacks
+* Public or private harassment
+* Publishing others' private information, such as a physical or
+  electronic address, without explicit permission
+* Other conduct which could reasonably be considered inappropriate in a
+  professional setting
+
+## Our Responsibilities
+
+Project maintainers are responsible for clarifying the standards of
+acceptable behavior and are expected to take appropriate and fair
+corrective action in response to any instances of unacceptable behavior.
+
+Project maintainers have the right and responsibility to remove, edit,
+or reject comments, commits, code, wiki edits, issues, and other
+contributions that are not aligned to this Code of Conduct, or to ban
+temporarily or permanently any contributor for other behaviors that they
+deem inappropriate, threatening, offensive, or harmful.
+
+## Scope
+
+This Code of Conduct applies both within project spaces and in public 
+spaces when an individual is representing the project or its community. 
+Examples of representing a project or community include using an 
+official project e-mail address, posting via an official social media 
+account, or acting as an appointed representative at an online or 
+offline event. Representation of a project may be further defined and 
+clarified by project maintainers.
+
+## Enforcement
+
+Instances of abusive, harassing, or otherwise unacceptable behavior may 
+be reported by contacting the project team at iekechukwu@gmail.com. All 
+complaints will be reviewed and investigated and will result in a 
+response that is deemed necessary and appropriate to the circumstances. 
+The project team is obligated to maintain confidentiality with regard to 
+the reporter of an incident. Further details of specific enforcement 
+policies may be posted separately.
+
+Project maintainers who do not follow or enforce the Code of Conduct in 
+good faith may face temporary or permanent repercussions as determined 
+by other members of the project's leadership.
+
+## Attribution
+
+This Code of Conduct is adapted from the
+[Contributor Covenant][homepage], version 1.4, available at 
+https://www.contributor-covenant.org/version/1/4/code-of-conduct.html
+
+[homepage]: https://www.contributor-covenant.org
+
+For answers to common questions about this code of conduct, see
+https://www.contributor-covenant.org/faq

+ 2 - 0
README.md

@@ -0,0 +1,2 @@
+![Notice, this repository was mirrored to here from Github](https://m1s5.c20.e2-5.dev/files/images/mirror-notice.svg)
+

+ 236 - 0
README.rst

@@ -0,0 +1,236 @@
+bandcamp-dl
+===========
+|PyPI pyversions| |PyPI download month| |PyPI license| |GitHub release| |GitHub commits|
+
+Download audio from `bandcamp.com`_
+
+Synopsis
+--------
+
+``bandcamp-dl URL``
+
+Installation
+------------
+
+From PyPI
+~~~~~~~~~
+
+``pip3 install bandcamp-downloader``
+
+Some linux distros may require that python3-pip is installed first.
+
+From Wheel
+~~~~~~~~~~
+
+1. Download the wheel (``.whl``) from PyPI or the Releases page
+2. ``cd`` to the directory containing the ``.whl`` file
+3. ``pip install <filename>.whl``
+
+[OSX] From Homebrew
+~~~~~~~~~~~~~~~~~~~
+
+``brew install bandcamp-dl``
+
+[Arch] From the AUR
+~~~~~~~~~~~~~~~~~~~
+
+``yay -S bandcamp-dl-git``
+
+From Source
+~~~~~~~~~~~
+
+1. Clone the project or `download and extract the zip`_
+2. ``cd`` to the project directory
+3. Run ``pip install .``
+
+Description
+-----------
+
+bandcamp-dl is a small command-line app to download audio from
+bandcamp.com. It requires the Python interpreter, version 3.4 (or
+higher) and is not platform specific. It is released to the public
+domain, which means you can modify it, redistribute it or use it how
+ever you like.
+
+Details
+-------
+
+::
+
+    Usage:
+        bandcamp-dl [options] [URL]
+
+    Arguments:
+        URL         Bandcamp album/track URL
+
+Options
+-------
+
+::
+
+    Options:
+      -h, --help            show this help message and exit
+      -v, --version         Show version
+      -d, --debug           Verbose logging
+      --artist ARTIST       Specify an artist's slug to download their full discography.
+      --track TRACK         Specify a track's slug to download a single track. Must be used with --artist.
+      --album ALBUM         Specify an album's slug to download a single album. Must be used with --artist.
+      --template TEMPLATE   Output filename template, default: %{artist}/%{album}/%{track} - %{title}
+      --base-dir BASE_DIR   Base location of which all files are downloaded
+      -f, --full-album      Download only if all tracks are available
+      -o, --overwrite       Overwrite tracks that already exist. Default is False.
+      -n, --no-art          Skip grabbing album art
+      -e, --embed-lyrics    Embed track lyrics (If available)
+      -g, --group           Use album/track Label as iTunes grouping
+      -r, --embed-art       Embed album art (If available)
+      --cover-quality {0,10,16}
+                              Set the cover art quality. 0 is source, 10 is album page (1200x1200), 16 is default embed
+                              (700x700).
+      -y, --no-slugify      Disable slugification of track, album, and artist names
+      -c OK_CHARS, --ok-chars OK_CHARS
+                              Specify allowed chars in slugify, default: -_~
+      -s SPACE_CHAR, --space-char SPACE_CHAR
+                              Specify the char to use in place of spaces, default: -
+      -a, --ascii-only      Only allow ASCII chars (北京 (capital of china) -> bei-jing-capital-of-china)
+      -k, --keep-spaces     Retain whitespace in filenames
+      -x {lower,upper,camel,none}, --case-convert {lower,upper,camel,none}
+                              Specify the char case conversion logic, default: lower
+      --no-confirm          Override confirmation prompts. Use with caution
+      --embed-genres        Embed album/track genres
+      --truncate-album LENGTH
+                              Truncate album title to a maximum length. 0 for no limit.
+      --truncate-track LENGTH
+                              Truncate track title to a maximum length. 0 for no limit.
+
+Filename Template
+-----------------
+
+The ``--template`` option allows users to indicate a template for the
+output file names and directories. Templates can be built using special
+tokens with the format of ``%{artist}``. Here is a list of allowed
+tokens:
+
+-  ``trackartist``: The artist name.
+-  ``artist``: The album artist name.
+-  ``album``: The album name.
+-  ``track``: The track number.
+-  ``title``: The track title.
+-  ``date``: The album date.
+-  ``label``: The album label.
+
+The default template is: ``%{artist}/%{album}/%{track} - %{title}``.
+
+Bugs
+----
+
+Bugs should be reported `here`_. Please include the URL and/or options
+used as well as the output when using the `--debug` option.
+
+For discussions, join us in `Discord`_.
+
+When you submit a request, please re-read it once to avoid a couple of
+mistakes (you can and should use this as a checklist):
+
+Are you using the latest version?
+---------------------------------
+
+This should report that you're up-to-date. About 20% of the reports we
+receive are already fixed, but people are using outdated versions. This
+goes for feature requests as well.
+
+Is the issue already documented?
+--------------------------------
+
+Make sure that someone has not already opened the issue you're trying to
+open. Search at the top of the window or at `Issues`_. If there is an
+issue, feel free to write something along the lines of "This affects me
+as well, with version 2015.01.01. Here is some more information on the
+issue: ...". While some issues may be old, a new post into them often
+spurs rapid activity.
+
+Why are existing options not enough?
+------------------------------------
+
+Before requesting a new feature, please have a quick peek at 
+`the list of supported options`_.  Many feature requests are for
+features that actually exist already!  Please, absolutely do show off
+your work in the issue report and detail how the existing similar
+options do *not* solve your problem.
+
+Does the issue involve one problem, and one problem only?
+---------------------------------------------------------
+
+Some of our users seem to think there is a limit of issues they can or
+should open. There is no limit of issues they can or should open. While
+it may seem appealing to be able to dump all your issues into one
+ticket, that means that someone who solves one of your issues cannot
+mark the issue as closed. Typically, reporting a bunch of issues leads
+to the ticket lingering since nobody wants to attack that behemoth,
+until someone mercifully splits the issue into multiple ones.
+
+Is anyone going to need the feature?
+------------------------------------
+
+Only post features that you (or an incapable friend you can
+personally talk to) require. Do not post features because they seem like
+a good idea. If they are really useful, they will be requested by
+someone who requires them.
+
+Is your question about bandcamp-dl?
+-----------------------------------
+
+It may sound strange, but some bug reports we receive are completely
+unrelated to bandcamp-dl and relate to a different or even the
+reporter's own application. Please make sure that you are actually using
+bandcamp-dl. If you are using a UI for bandcamp-dl, report the bug to
+the maintainer of the actual application providing the UI. On the other
+hand, if your UI for bandcamp-dl fails in some way you believe is
+related to bandcamp-dl, by all means, go ahead and report the bug.
+
+Dependencies
+------------
+
+- `BeautifulSoup4`_ - HTML Parsing
+- `Demjson`_- JavaScript dict to JSON conversion
+- `Mutagen`_ - ID3 Encoding
+- `Requests`_ - for retrieving the HTML
+
+Copyright
+---------
+
+bandcamp-dl is released into the public domain by the copyright holders
+
+This README file was inspired by the `youtube-dl`_ docs and is likewise
+released into the public domain.
+
+
+.. _download and extract the zip: https://github.com/evolution0/bandcamp-dl/archive/master.zip
+.. _here: https://github.com/evolution0/bandcamp-dl/issues
+.. _Discord: https://discord.gg/nwdT4MP
+.. _bandcamp.com: https://www.bandcamp.com
+.. _Issues: https://github.com/evolution0/bandcamp-dl/search?type=Issues
+.. _the list of supported options: https://github.com/evolution0/bandcamp-dl/blob/master/README.rst#synopsis
+.. _BeautifulSoup4: https://pypi.python.org/pypi/beautifulsoup4 
+.. _Demjson: https://pypi.python.org/pypi/demjson
+.. _Mutagen: https://pypi.python.org/pypi/mutagen
+.. _Requests: https://pypi.python.org/pypi/requests
+.. _youtube-dl: https://github.com/rg3/youtube-dl/blob/master/README.md
+
+.. |PyPI pyversions| image:: https://img.shields.io/pypi/pyversions/bandcamp-downloader.svg
+   :target: https://pypi.python.org/pypi/bandcamp-downloader/
+
+
+.. |PyPI download month| image:: https://img.shields.io/pypi/dm/bandcamp-downloader.svg
+   :target: https://pypi.python.org/pypi/bandcamp-downloader/
+
+
+.. |PyPI license| image:: https://img.shields.io/pypi/l/bandcamp-downloader.svg
+   :target: https://pypi.python.org/pypi/bandcamp-downloader/
+
+
+.. |GitHub release| image:: https://img.shields.io/github/release/evolution0/bandcamp-dl.svg
+   :target: https://GitHub.com/evolution0/bandcamp-dl/releases/
+
+
+.. |GitHub commits| image:: https://img.shields.io/github/commits-since/evolution0/bandcamp-dl/v0.0.17.svg
+   :target: https://GitHub.com/evolution0/bandcamp-dl/commit/

+ 24 - 0
UNLICENSE

@@ -0,0 +1,24 @@
+This is free and unencumbered software released into the public domain.
+
+Anyone is free to copy, modify, publish, use, compile, sell, or
+distribute this software, either in source code form or as a compiled
+binary, for any purpose, commercial or non-commercial, and by any
+means.
+
+In jurisdictions that recognize copyright laws, the author or authors
+of this software dedicate any and all copyright interest in the
+software to the public domain. We make this dedication for the benefit
+of the public at large and to the detriment of our heirs and
+successors. We intend this dedication to be an overt act of
+relinquishment in perpetuity of all present and future rights to this
+software under copyright law.
+
+THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
+EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
+MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT.
+IN NO EVENT SHALL THE AUTHORS BE LIABLE FOR ANY CLAIM, DAMAGES OR
+OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE,
+ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR
+OTHER DEALINGS IN THE SOFTWARE.
+
+For more information, please refer to <http://unlicense.org/>

+ 16 - 0
bandcamp_dl/__init__.py

@@ -0,0 +1,16 @@
+import importlib.metadata
+import pathlib
+
+try:
+    import tomllib
+except ModuleNotFoundError:
+    import toml
+
+try:
+    __version__ = importlib.metadata.version("bandcamp-downloader")
+except importlib.metadata.PackageNotFoundError:
+    # If running in a development environment we ideally are not installed in the venv as such fetch from pyproject.toml
+    here = pathlib.Path(__file__).parent.parent.resolve()
+    with open(f'{here}/pyproject.toml', 'rb') as pyproject:
+        metadata = tomllib.load(pyproject)
+        __version__ = metadata['project']['version']

+ 144 - 0
bandcamp_dl/__main__.py

@@ -0,0 +1,144 @@
+import argparse
+import logging
+import pathlib
+import sys
+
+from bandcamp_dl import __version__
+from bandcamp_dl.bandcamp import Bandcamp
+from bandcamp_dl.bandcampdownloader import BandcampDownloader
+from bandcamp_dl import config
+
+from urllib.parse import urlparse
+
+
+def main():
+    # parse config if found, else create it
+    conf = config.Config()
+
+    parser = argparse.ArgumentParser()
+    parser.add_argument('URL', help="Bandcamp album/track URL", nargs="*")
+    parser.add_argument('-v', '--version', action='store_true', help='Show version')
+    parser.add_argument('-d', '--debug', action='store_true', help='Verbose logging', default=conf.debug)
+    parser.add_argument('--artist', help="Specify an artist's slug to download their full discography.")
+    parser.add_argument('--track', help="Specify a track's slug to download a single track. Must be used with --artist.")
+    parser.add_argument('--album', help="Specify an album's slug to download a single album. Must be used with --artist.")
+    parser.add_argument('--template', help=f"Output filename template, default: "
+                        f"{conf.template.replace('%', '%%')}", default=conf.template)
+    parser.add_argument('--base-dir', help='Base location of which all files are downloaded',
+                        default=conf.base_dir)
+    parser.add_argument('-f', '--full-album', help='Download only if all tracks are available',
+                        action='store_true')
+    parser.add_argument('-o', '--overwrite', action='store_true',
+                        help=f'Overwrite tracks that already exist. Default is {conf.overwrite}.',
+                        default=conf.overwrite)
+    parser.add_argument('-n', '--no-art', help='Skip grabbing album art', action='store_true',
+                        default=conf.no_art)
+    parser.add_argument('-e', '--embed-lyrics', help='Embed track lyrics (If available)',
+                        action='store_true', default=conf.embed_lyrics)
+    parser.add_argument('-g', '--group', help='Use album/track Label as iTunes grouping',
+                        action='store_true', default=conf.group)
+    parser.add_argument('-r', '--embed-art', help='Embed album art (If available)',
+                        action='store_true', default=conf.embed_art)
+    parser.add_argument('--cover-quality', help='Set the cover art quality. 0 is source, 10 is album page (1200x1200), 16 is default embed (700x700).',
+                        default=conf.cover_quality, type=int, choices=[0, 10, 16])
+    parser.add_argument('--untitled-path-from-slug', help='For albums titled untitled, use the URL slug to generate the folder path.',
+                        action='store_true', default=conf.untitled_path_from_slug)
+    parser.add_argument('-y', '--no-slugify', action='store_true', default=conf.no_slugify,
+                        help='Disable slugification of track, album, and artist names')
+    parser.add_argument('-c', '--ok-chars', default=conf.ok_chars,
+                        help=f'Specify allowed chars in slugify, default: {conf.ok_chars}')
+    parser.add_argument('-s', '--space-char', help=f'Specify the char to use in place of spaces, '
+                        f'default: {conf.space_char}', default=conf.space_char)
+    parser.add_argument('-a', '--ascii-only', help='Only allow ASCII chars (北京 (capital of '
+                        'china) -> bei-jing-capital-of-china)', action='store_true',
+                        default=conf.ascii_only)
+    parser.add_argument('-k', '--keep-spaces', help='Retain whitespace in filenames',
+                        action='store_true', default=conf.keep_spaces)
+    parser.add_argument('-x', '--case-convert', help=f'Specify the char case conversion logic, '
+                        f'default: {conf.case_mode}', default=conf.case_mode, dest='case_mode',
+                        choices=[config.CASE_LOWER, config.CASE_UPPER, config.CASE_CAMEL,
+                        config.CASE_NONE]) 
+    parser.add_argument('--no-confirm', help='Override confirmation prompts. Use with caution',
+                        action='store_true', default=conf.no_confirm)
+    parser.add_argument('--embed-genres', help='Embed album/track genres',
+                        action='store_true', default=conf.embed_genres)
+    parser.add_argument('--truncate-album', metavar='LENGTH', type=int, default=0,
+                        help='Truncate album title to a maximum length. 0 for no limit.')
+    parser.add_argument('--truncate-track', metavar='LENGTH', type=int, default=0,
+                        help='Truncate track title to a maximum length. 0 for no limit.')
+
+
+    arguments = parser.parse_args()
+    if arguments.version:
+        sys.stdout.write(f"bandcamp-dl {__version__}\n")
+        return
+
+    if arguments.debug:
+        logging.basicConfig(level=logging.DEBUG)
+    else:
+        logging.basicConfig()
+    logging_handle = "bandcamp-dl"
+    logger = logging.getLogger(logging_handle)
+
+    # TODO: Its possible to break bandcamp-dl temporarily by simply erasing a line in the config, catch this and warn.
+    logger.debug(f"Config/Args: {arguments}")
+    if not arguments.URL and not arguments.artist:
+        parser.print_usage()
+        sys.stderr.write(f"{pathlib.Path(sys.argv[0]).name}: error: the following arguments are "
+                         f"required: URL or --artist\n")
+        sys.exit(2)
+
+    for arg, val in [('base_dir', config.USER_HOME), ('template', config.TEMPLATE),
+                     ('ok_chars', config.OK_CHARS), ('space_char', config.SPACE_CHAR)]:
+        if not getattr(arguments, arg):
+            setattr(arguments, arg, val)
+    bandcamp = Bandcamp()
+
+    if arguments.artist and arguments.album:
+        urls = Bandcamp.generate_album_url(arguments.artist, arguments.album, "album")
+    elif arguments.artist and arguments.track:
+        urls = Bandcamp.generate_album_url(arguments.artist, arguments.track, "track")
+    elif arguments.artist:
+        urls = Bandcamp.get_full_discography(bandcamp, arguments.artist, "music")
+    else:
+        urls = []
+        for url in arguments.URL:
+            parsed_url = urlparse(url)
+            if parsed_url.netloc.endswith('.bandcamp.com') and (parsed_url.path == '/music' or parsed_url.path == '/' or parsed_url.path == ''):
+                artist = parsed_url.netloc.split('.')[0]
+                print(f"Found artist page, fetching full discography for: {artist}")
+                urls.extend(bandcamp.get_full_discography(artist, "music"))
+            else:
+                urls.append(url)
+
+
+    album_list = []
+
+    for url in urls:
+        if "/album/" not in url and "/track/" not in url:
+            continue
+        logger.debug("\n\tURL: %s", url)
+        album_list.append(bandcamp.parse(url, not arguments.no_art, arguments.embed_lyrics, arguments.embed_genres,
+                                         arguments.debug, arguments.cover_quality))
+
+    for album in album_list:
+        logger.debug(f" Album data:\n\t{album}")
+        if arguments.full_album and not album['full']:
+            print("Full album not available. Skipping ", album['title'], " ...")
+            # Remove not-full albums BUT continue with the rest of the albums.
+            album_list.remove(album)
+
+    if arguments.URL or arguments.artist:
+        logger.debug("Preparing download process..")
+        for album in album_list:
+            bandcamp_downloader = BandcampDownloader(arguments, album['url'])
+            logger.debug("Initiating download process..")
+            bandcamp_downloader.start(album)
+            # Add a newline to stop prompt mangling
+            print("")
+    else:
+        logger.debug(r" /!\ Something went horribly wrong /!\ ")
+
+
+if __name__ == '__main__':
+    main()

+ 348 - 0
bandcamp_dl/bandcamp.py

@@ -0,0 +1,348 @@
+import sys
+import datetime
+import json
+import logging
+
+import bs4
+import requests
+from requests.adapters import HTTPAdapter
+from urllib3.util import create_urllib3_context
+from urllib.parse import urlparse, urlunparse, urljoin
+
+from bandcamp_dl import __version__
+from bandcamp_dl.bandcampjson import BandcampJSON
+
+class SSLAdapter(HTTPAdapter):
+    def __init__(self, ssl_context=None, **kwargs):
+        self.ssl_context = ssl_context
+        super().__init__(**kwargs)
+
+    def init_poolmanager(self, *args, **kwargs):
+        kwargs['ssl_context'] = self.ssl_context
+        return super().init_poolmanager(*args, **kwargs)
+
+    def proxy_manager_for(self, *args, **kwargs):
+        kwargs['ssl_context'] = self.ssl_context
+        return super().proxy_manager_for(*args, **kwargs)
+    
+# Create the SSL context with the custom ciphers
+ctx = create_urllib3_context()
+ctx.load_default_certs()
+
+DEFAULT_CIPHERS = ":".join(
+    [
+        "ECDHE+AESGCM",
+        "ECDHE+CHACHA20",
+        "DHE+AESGCM",
+        "DHE+CHACHA20",
+        "ECDH+AESGCM",
+        "DH+AESGCM",
+        "ECDH+AES",
+        "DH+AES",
+        "RSA+AESGCM",
+        "RSA+AES",
+        "!aNULL",
+        "!eNULL",
+        "!MD5",
+        "!DSS",
+        "!AESCCM",
+    ]
+)
+ctx.set_ciphers(DEFAULT_CIPHERS)
+
+class Bandcamp:
+    def __init__(self):
+        self.headers = {'User-Agent': f'bandcamp-dl/{__version__} '
+                        f'(https://github.com/evolution0/bandcamp-dl)'}
+        self.soup = None
+        self.tracks = None
+        self.logger = logging.getLogger("bandcamp-dl").getChild("Main")
+        
+        # Mount the adapter with the custom SSL context to the session
+        self.session = requests.Session()
+        self.adapter = SSLAdapter(ssl_context=ctx)
+        self.session.mount('https://', self.adapter)
+
+    def parse(self, url: str, art: bool = True, lyrics: bool = False, genres: bool = False,
+              debugging: bool = False, cover_quality: int = 0) -> dict or None:
+        """Requests the page, cherry-picks album info
+
+        :param url: album/track url
+        :param art: if True download album art
+        :param lyrics: if True fetch track lyrics
+        :param genres: if True fetch track tags
+        :param debugging: if True then verbose output
+        :return: album metadata
+        """
+
+        try:
+            response = self.session.get(url, headers=self.headers)
+        except requests.exceptions.MissingSchema:
+            return None
+
+        if not response.ok:
+            self.logger.debug(" Status code: %s", response.status_code)
+            print(f"The Album/Track requested does not exist at: {url}")
+            sys.exit(2)
+
+
+        try:
+            self.soup = bs4.BeautifulSoup(response.text, "lxml")
+        except bs4.FeatureNotFound:
+            self.soup = bs4.BeautifulSoup(response.text, "html.parser")
+
+        self.logger.debug(" Generating BandcampJSON..")
+        bandcamp_json = BandcampJSON(self.soup, debugging).generate()
+        page_json = {}
+        for entry in bandcamp_json:
+            page_json = {**page_json, **json.loads(entry)}
+        self.logger.debug(" BandcampJSON generated..")
+
+        self.logger.debug(" Generating Album..")
+        self.tracks = page_json['trackinfo']
+
+        track_ids = {}
+        if 'track' in page_json and 'itemListElement' in page_json['track']:
+            for item in page_json['track']['itemListElement']:
+                track_url = item['item']['@id']
+                for prop in item['item'].get('additionalProperty', []):
+                    if prop.get('name') == 'track_id':
+                        track_ids[track_url] = prop.get('value')
+                        break
+
+        track_nums = [track['track_num'] for track in self.tracks]
+        if len(track_nums) != len(set(track_nums)):
+            self.logger.debug(" Duplicate track numbers found, re-numbering based on position..")
+            track_positions = {}
+            if 'track' in page_json and 'itemListElement' in page_json['track']:
+                for item in page_json['track']['itemListElement']:
+                    track_url = item['item']['@id']
+                    position = item['position']
+                    track_positions[track_url] = position
+
+            if "/track/" in page_json['url']:
+                artist_url = page_json['url'].rpartition('/track/')[0]
+            else:
+                artist_url = page_json['url'].rpartition('/album/')[0]
+
+            for track in self.tracks:
+                full_track_url = f"{artist_url}{track['title_link']}"
+                if full_track_url in track_positions:
+                    track['track_num'] = track_positions[full_track_url]
+                else:
+                    self.logger.debug(f" Could not find position for track: {full_track_url}")
+                    track['track_num'] = self.tracks.index(track) + 1
+        
+        album_release = page_json['album_release_date']
+        if album_release is None:
+            album_release = page_json['current']['release_date']
+            if album_release is None:
+                album_release = page_json['embed_info']['item_public']
+
+        try:
+            album_title = page_json['current']['title']
+        except KeyError:
+            album_title = page_json['trackinfo'][0]['title']
+
+        try:
+            label = page_json['item_sellers'][f'{page_json["current"]["selling_band_id"]}']['name']
+        except KeyError:
+            label = None
+
+        album_id = None
+        track_id_from_music_recording = None
+
+        if page_json.get('@type') == 'MusicRecording':
+            if 'additionalProperty' in page_json:
+                for prop in page_json['additionalProperty']:
+                    if prop.get('name') == 'track_id':
+                        track_id_from_music_recording = prop.get('value')
+                        album_id = track_id_from_music_recording
+                        self.logger.debug(f" Single track page, found track_id: {track_id_from_music_recording}")
+                        break
+        elif page_json.get('@type') == 'MusicAlbum':
+            if 'albumRelease' in page_json:
+                for release in page_json['albumRelease']:
+                    if 'additionalProperty' in release:
+                        for prop in release['additionalProperty']:
+                            if prop.get('name') == 'item_id':
+                                album_id = prop.get('value')
+                                self.logger.debug(f" Album page, found album_id: {album_id}")
+                                break
+                    if album_id:
+                        break
+
+        album = {
+            "tracks": [],
+            "title": album_title,
+            "artist": page_json['artist'],
+            "label": label,
+            "full": False,
+            "art": "",
+            "date": str(datetime.datetime.strptime(album_release, "%d %b %Y %H:%M:%S GMT").year),
+            "url": url,
+            "genres": "",
+            "album_id": album_id
+        }
+
+        if "/track/" in page_json['url']:
+            artist_url = page_json['url'].rpartition('/track/')[0]
+        else:
+            artist_url = page_json['url'].rpartition('/album/')[0]
+
+        for track in self.tracks:
+            full_track_url = f"{artist_url}{track['title_link']}"
+            if track_id_from_music_recording:
+                track['track_id'] = track_id_from_music_recording
+            else:
+                track['track_id'] = track_ids.get(full_track_url)
+
+            if lyrics:
+                track['lyrics'] = self.get_track_lyrics(f"{artist_url}"
+                                                        f"{track['title_link']}#lyrics")
+
+            if track['file'] is not None:
+                track = self.get_track_metadata(track)
+                album['tracks'].append(track)
+
+        album['full'] = self.all_tracks_available()
+        if art:
+            album['art'] = self.get_album_art(cover_quality)
+        if genres:
+            album['genres'] = "; ".join(page_json['keywords'])
+
+        self.logger.debug(" Album generated..")
+        self.logger.debug(" Album URL: %s", album['url'])
+
+        return album
+
+    def get_track_lyrics(self, track_url):
+        self.logger.debug(" Fetching track lyrics..")
+        track_page = self.session.get(track_url, headers=self.headers)
+        try:
+            track_soup = bs4.BeautifulSoup(track_page.text, "lxml")
+        except bs4.FeatureNotFound:
+            track_soup = bs4.BeautifulSoup(track_page.text, "html.parser")
+        track_lyrics = track_soup.find("div", {"class": "lyricsText"})
+        if track_lyrics:
+            self.logger.debug(" Lyrics retrieved..")
+            return track_lyrics.text
+        else:
+            self.logger.debug(" Lyrics not found..")
+            return ""
+
+    def all_tracks_available(self) -> bool:
+        """Verify that all tracks have a url
+
+        :return: True if all urls accounted for
+        """
+        for track in self.tracks:
+            if track['file'] is None:
+                return False
+        return True
+
+    def get_track_metadata(self, track: dict or None) -> dict:
+        """Extract individual track metadata
+
+        :param track: track dict
+        :return: track metadata dict
+        """
+        self.logger.debug(" Generating track metadata..")
+        track_metadata = {
+            "duration": track['duration'],
+            "track": str(track['track_num']),
+            "title": track['title'],
+            "artist": track['artist'],
+            "track_id": track.get('track_id'),
+            "url": None
+        }
+
+        if 'mp3-128' in track['file']:
+            if 'https' in track['file']['mp3-128']:
+                track_metadata['url'] = track['file']['mp3-128']
+            else:
+                track_metadata['url'] = "http:" + track['file']['mp3-128']
+        else:
+            track_metadata['url'] = None
+
+        if track['has_lyrics'] is not False:
+            if track['lyrics'] is not None:
+                track_metadata['lyrics'] = track['lyrics'].replace('\\r\\n', '\n')
+
+        self.logger.debug(" Track metadata generated..")
+        return track_metadata
+
+    @staticmethod
+    def generate_album_url(artist: str, slug: str, page_type: str) -> str:
+        """Generate an album url based on the artist and album name
+
+        :param artist: artist name
+        :param slug: Slug of album/track
+        :param page_type: Type of page album/track
+        :return: url as str
+        """
+        return f"http://{artist}.bandcamp.com/{page_type}/{slug}"
+
+    def get_album_art(self, quality: int = 0) -> str:
+        """Find and retrieve album art url from page
+
+        :param quality: The quality of the album art to retrieve
+        :return: url as str
+        """
+        try:
+            url = self.soup.find(id='tralbumArt').find_all('a')[0]['href']
+            return f"{url[:-6]}{quality}{url[-4:]}"
+        except None:
+            pass
+
+    def get_full_discography(self, artist: str, page_type: str) -> list:
+        """Generate a list of album and track urls based on the artist name
+
+        :param artist: artist name
+        :param page_type: Type of page, it should be music but it's a parameter so it's not
+                          hardcoded
+        :return: urls as list of strs
+        """
+
+        album_urls = set()
+
+        music_page_url = f"https://{artist}.bandcamp.com/{page_type}"
+        self.logger.info(f"Scraping discography from: {music_page_url}")
+
+        try:
+            response = self.session.get(music_page_url, headers=self.headers)
+        except requests.exceptions.RequestException as e:
+            self.logger.error(f"Could not fetch artist page {music_page_url}: {e}")
+            return []
+
+        try:
+            soup = bs4.BeautifulSoup(response.text, "lxml")
+        except bs4.FeatureNotFound:
+            soup = bs4.BeautifulSoup(response.text, "html.parser")
+
+        music_grid = soup.find('ol', {'id': 'music-grid'})
+        if not music_grid:
+            self.logger.warning("Could not find music grid on the page. No albums found.")
+            return []
+
+        if 'data-client-items' in music_grid.attrs:
+            self.logger.debug("Found data-client-items attribute. Parsing for album URLs.")
+            try:
+                json_string = bs4.BeautifulSoup(music_grid['data-client-items'], "html.parser").text
+                items = json.loads(json_string)
+                for item in items:
+                    if 'page_url' in item:
+                        full_url = urljoin(music_page_url, item['page_url'])
+                        album_urls.add(full_url)
+            except (json.JSONDecodeError, TypeError) as e:
+                self.logger.error(f"Failed to parse data-client-items JSON: {e}")
+
+        self.logger.debug("Scraping all <li> elements in the music grid for links.")
+        for a in music_grid.select('li.music-grid-item a'):
+            href = a.get('href')
+            if href:
+                full_url = urljoin(music_page_url, href)
+                album_urls.add(full_url)
+
+        self.logger.info(f"Found a total of {len(album_urls)} unique album/track links.")
+        return list(album_urls)

+ 313 - 0
bandcamp_dl/bandcampdownloader.py

@@ -0,0 +1,313 @@
+import logging
+import os
+import re
+import shutil
+
+from mutagen import mp3
+from mutagen import id3
+import requests
+import slugify
+
+from bandcamp_dl import __version__
+from bandcamp_dl.config import CASE_LOWER, CASE_UPPER, CASE_CAMEL, CASE_NONE
+
+
+def print_clean(msg):
+    terminal_size = shutil.get_terminal_size()
+    print(f'{msg}{" " * (int(terminal_size[0]) - len(msg))}', end='')
+
+
+class BandcampDownloader:
+    def __init__(self, config, urls=None):
+        """Initialize variables we will need throughout the Class
+
+        :param config: user config/args
+        :param urls: list of urls
+        """
+        self.headers = {'User-Agent': f'bandcamp-dl/{__version__} '
+                        f'(https://github.com/evolution0/bandcamp-dl)'}
+        self.session = requests.Session()
+        self.logger = logging.getLogger("bandcamp-dl").getChild("Downloader")
+
+        if type(urls) is str:
+            self.urls = [urls]
+
+        self.config = config
+        self.urls = urls
+
+    def start(self, album: dict):
+        """Start album download process
+
+        :param album: album dict
+        """
+
+        if not album['full'] and not self.config.no_confirm:
+            choice = input("Track list incomplete, some tracks may be private, download anyway? "
+                           "(yes/no): ").lower()
+            if choice == "yes" or choice == "y":
+                print("Starting download process.")
+                self.download_album(album)
+            else:
+                print("Cancelling download process.")
+                return None
+        else:
+            self.download_album(album)
+
+    def template_to_path(self, track: dict, ascii_only, ok_chars, space_char, keep_space,
+                         case_mode) -> str:
+        """Create valid filepath based on template
+
+        :param track: track metadata
+        :param ok_chars: optional chars to allow
+        :param ascii_only: allow only ascii chars in filename
+        :param keep_space: retain whitespace in filename
+        :param case_mode: char case conversion logic (or none / retain)
+        :param space_char: char to use in place of spaces
+        :return: filepath
+        """
+        self.logger.debug(" Generating filepath/trackname..")
+        path = self.config.template
+        self.logger.debug(f"\n\tTemplate: {path}")
+
+        def slugify_preset(content):
+            retain_case = case_mode != CASE_LOWER
+            if case_mode == CASE_UPPER:
+                content = content.upper()
+            if case_mode == CASE_CAMEL:
+                content = re.sub(r'(((?<=\s)|^|-)[a-z])', lambda x: x.group().upper(), content.lower())
+            slugged = slugify.slugify(content, ok=ok_chars, only_ascii=ascii_only,
+                                      spaces=keep_space, lower=not retain_case,
+                                      space_replacement=space_char)
+            return slugged
+
+        template_tokens = ['trackartist', 'artist', 'album', 'title', 'date', 'label', 'track', 'album_id', 'track_id']
+        for token in template_tokens:
+            key = token
+            if token == 'trackartist':
+                key = 'artist'
+            elif token == 'artist':
+                key = 'albumartist'
+
+            if key == 'artist' and track.get('artist') is None:
+                self.logger.debug('Track artist is None, replacing with album artist')                
+                track['artist'] = track.get('albumartist')
+
+            if self.config.untitled_path_from_slug and token == 'album' and track['album'].lower() == 'untitled':
+                track['album'] = track['url'].split("/")[-1].replace("-"," ")
+
+            if token == 'track' and track['track'] == 'None':
+                track['track'] = "Single"
+            else:
+                track['track'] = str(track['track']).zfill(2)
+
+            if self.config.no_slugify:
+                replacement = str(track.get(key, ""))
+            else:
+                replacement = slugify_preset(track.get(key, ""))
+
+            path = path.replace(f'%{{{token}}}', replacement)
+
+        if self.config.base_dir is not None:
+            path = f"{self.config.base_dir}/{path}.mp3"
+        else:
+            path = f"{path}.mp3"
+
+        self.logger.debug(" filepath/trackname generated..")
+        self.logger.debug(f"\n\tPath: {path}")
+        return path
+
+
+    def create_directory(self, filename: str) -> str:
+        """Create directory based on filename if it doesn't exist
+
+        :param filename: full filename
+        :return: directory path
+        """
+        directory = os.path.dirname(filename)
+        self.logger.debug(f" Directory:\n\t{directory}")
+        self.logger.debug(" Directory doesn't exist, creating..")
+        if not os.path.exists(directory):
+            os.makedirs(directory)
+
+        return directory
+
+    def download_album(self, album: dict) -> bool:
+        """Download all MP3 files in the album
+
+        :param album: album dict
+        :return: True if successful
+        """
+        for track_index, track in enumerate(album['tracks']):
+            track_meta = {"artist": track['artist'],
+                          "albumartist": album['artist'],
+                          "label": album['label'],
+                          "album": album['title'],
+                          "title": track['title'].replace(f"{track['artist']} - ", "", 1),
+                          "track": track['track'],
+                          "track_id": track['track_id'],
+                          "album_id": album['album_id'],
+                          # TODO: Find out why the 'lyrics' key seems to vanish.
+                          "lyrics": track.get('lyrics', ""),
+                          "date": album['date'],
+                          "url": album['url'],
+                          "genres": album['genres']}
+
+            path_meta = track_meta.copy()
+
+            if self.config.truncate_album > 0 and len(path_meta['album']) > self.config.truncate_album:
+                path_meta['album'] = path_meta['album'][:self.config.truncate_album]
+
+            if self.config.truncate_track > 0 and len(path_meta['title']) > self.config.truncate_track:
+                path_meta['title'] = path_meta['title'][:self.config.truncate_track]
+
+            self.num_tracks = len(album['tracks'])
+            self.track_num = track_index + 1
+
+            filepath = self.template_to_path(path_meta, self.config.ascii_only,
+                                            self.config.ok_chars, self.config.space_char,
+                                            self.config.keep_spaces, self.config.case_mode)
+            filepath = filepath + ".tmp"
+            filename = filepath.rsplit('/', 1)[1]
+            dirname = self.create_directory(filepath)
+
+            self.logger.debug(" Current file:\n\t%s", filepath)
+
+            if album['art'] and not os.path.exists(dirname + "/cover.jpg"):
+                try:
+                    with open(dirname + "/cover.jpg", "wb") as f:
+                        r = self.session.get(album['art'], headers=self.headers)
+                        f.write(r.content)
+                    self.album_art = dirname + "/cover.jpg"
+                except Exception as e:
+                    print(e)
+                    print("Couldn't download album art.")
+
+            attempts = 0
+            skip = False
+
+            while True:
+                try:
+                    r = self.session.get(track['url'], headers=self.headers, stream=True)
+                    file_length = int(r.headers.get('content-length', 0))
+                    total = int(file_length / 100)
+                    # If file exists and is still a tmp file skip downloading and encode
+                    if os.path.exists(filepath):
+                        self.write_id3_tags(filepath, track_meta)
+                        # Set skip to True so that we don't try encoding again
+                        skip = True
+                        # break out of the try/except and move on to the next file
+                        break
+                    elif os.path.exists(filepath[:-4]) and self.config.overwrite is not True:
+                        print(f"File: {filename[:-4]} already exists and is complete, skipping..")
+                        skip = True
+                        break
+                    with open(filepath, "wb") as f:
+                        if file_length is None:
+                            f.write(r.content)
+                        else:
+                            dl = 0
+                            for data in r.iter_content(chunk_size=total):
+                                dl += len(data)
+                                f.write(data)
+                                if not self.config.debug:
+                                    done = int(50 * dl / file_length)
+                                    print_clean(f'\r({self.track_num}/{self.num_tracks}) '
+                                                f'[{"=" * done}{" " * (50 - done)}] :: '
+                                                f'Downloading: {filename[:-8]}')
+                    local_size = os.path.getsize(filepath)
+                    # if the local filesize before encoding doesn't match the remote filesize
+                    # redownload
+                    if local_size != file_length and attempts != 3:
+                        print(f"{filename} is incomplete, retrying..")
+                        continue
+                    # if the maximum number of retry attempts is reached give up and move on
+                    elif attempts == 3:
+                        print("Maximum retries reached.. skipping.")
+                        # Clean up incomplete file
+                        os.remove(filepath)
+                        break
+                    # if all is well continue the download process for the rest of the tracks
+                    else:
+                        break
+                except Exception as e:
+                    print(e)
+                    print("Downloading failed..")
+                    return False
+            if skip is False:
+                self.write_id3_tags(filepath, track_meta)
+
+        if os.path.isfile(f"{self.config.base_dir}/{__version__}.not.finished"):
+            os.remove(f"{self.config.base_dir}/{__version__}.not.finished")
+
+        # Remove album art image as it is embedded
+        if self.config.embed_art and hasattr(self, "album_art"):
+            os.remove(self.album_art)
+
+        return True
+
+    def write_id3_tags(self, filepath: str, meta: dict):
+        """Write metadata to the MP3 file
+
+        :param filepath: name of mp3 file
+        :param meta: dict of track metadata
+        """
+        self.logger.debug(" Encoding process starting..")
+
+        filename = filepath.rsplit('/', 1)[1][:-8]
+
+        if not self.config.debug:
+            print_clean(f'\r({self.track_num}/{self.num_tracks}) [{"=" * 50}] '
+                        f':: Encoding: {filename}')
+
+        audio = mp3.MP3(filepath)
+        audio.delete()
+        audio["TIT2"] = id3._frames.TIT2(encoding=3, text=["title"])
+        audio["WOAF"] = id3._frames.WOAF(url=meta["url"])
+        audio.save(filename=None, v1=2)
+
+        audio = mp3.MP3(filepath)
+        if self.config.group and 'label' in meta:
+            audio["TIT1"] = id3._frames.TIT1(encoding=3, text=meta["label"])
+
+        if self.config.embed_lyrics:
+            audio["USLT"] = id3._frames.USLT(encoding=3, lang='eng', desc='', text=meta['lyrics'])
+
+        if self.config.embed_art:
+            with open(self.album_art, 'rb') as cover_img:
+                cover_bytes = cover_img.read()
+                audio["APIC"] = id3._frames.APIC(encoding=3, mime='image/jpeg', type=3,
+                                                 desc='Cover', data=cover_bytes)
+        if self.config.embed_genres:
+            audio["TCON"] = id3._frames.TCON(encoding=3, text=meta['genres'])
+        audio.save()
+
+        audio = mp3.EasyMP3(filepath)
+
+        if meta['track'].isdigit():
+            audio["tracknumber"] = meta['track']
+        else:
+            audio["tracknumber"] = '1'
+
+        if meta['artist'] is not None:
+            audio["artist"] = meta['artist']
+        else:
+            audio["artist"] = meta['albumartist']
+        audio["title"] = meta["title"]
+        audio["albumartist"] = meta['albumartist']
+        audio["album"] = meta['album']
+        audio["date"] = meta["date"]
+        audio.save()
+
+        self.logger.debug(" Encoding process finished..")
+        self.logger.debug(" Renaming:\n\t%s -to-> %s", filepath, filepath[:-4])
+
+        try:
+            os.rename(filepath, filepath[:-4])
+        except WindowsError:
+            os.remove(filepath[:-4])
+            os.rename(filepath, filepath[:-4])
+
+        if self.config.debug:
+            return
+
+        print_clean(f'\r({self.track_num}/{self.num_tracks}) [{"=" * 50}] :: Finished: {filename}')

+ 42 - 0
bandcamp_dl/bandcampjson.py

@@ -0,0 +1,42 @@
+import logging
+
+import demjson3
+
+
+class BandcampJSON:
+    def __init__(self, body, debugging: bool = False):
+        self.body = body
+        self.json_data = []
+        self.logger = logging.getLogger("bandcamp-dl").getChild("JSON")
+
+    def generate(self):
+        """Grabbing needed data from the page"""
+        self.get_pagedata()
+        self.get_js()
+        return self.json_data
+
+    def get_pagedata(self):
+        self.logger.debug(" Grab pagedata JSON..")
+        pagedata = self.body.find('div', {'id': 'pagedata'})['data-blob']
+        self.json_data.append(pagedata)
+
+    def get_js(self):
+        """Get <script> element containing the data we need and return the raw JS"""
+        self.logger.debug(" Grabbing embedded scripts..")
+        embedded_scripts_raw = [self.body.find("script", {"type": "application/ld+json"}).string]
+        for script in self.body.find_all('script'):
+            try:
+                album_info = script['data-tralbum']
+                embedded_scripts_raw.append(album_info)
+            except Exception:
+                continue
+        for script in embedded_scripts_raw:
+            js_data = self.js_to_json(script)
+            self.json_data.append(js_data)
+
+    def js_to_json(self, js_data):
+        """Convert JavaScript dictionary to JSON"""
+        self.logger.debug(" Converting JS to JSON..")
+        # Decode with demjson first to reformat keys and lists
+        decoded_js = demjson3.decode(js_data)
+        return demjson3.encode(decoded_js)

+ 121 - 0
bandcamp_dl/config.py

@@ -0,0 +1,121 @@
+import argparse
+import ast
+import json
+import os
+import pathlib
+import sys
+
+from bandcamp_dl import __version__
+
+TEMPLATE = '%{artist}/%{album}/%{track} - %{title}'
+OK_CHARS = '-_~'
+SPACE_CHAR = '-'
+CASE_LOWER = 'lower'
+CASE_UPPER = 'upper'
+CASE_CAMEL = 'camel'
+CASE_NONE = 'none'
+USER_HOME = pathlib.Path.home()
+# For Linux/BSD https://www.freedesktop.org/wiki/Software/xdg-user-dirs/
+# For Windows ans MacOS .appname is fine
+CONFIG_PATH = USER_HOME / (".config" if os.name == "posix" else ".bandcamp-dl") / "bandcamp-dl.json"
+OPTION_MIGRATION_FORWARD = "forward"
+OPTION_MIGRATION_REVERSE = "reverse"
+
+
+class Config(dict):
+
+    # TODO: change this to dataclass when support for Python < 3.7 is dropped
+    _defaults = {"base_dir": str(USER_HOME),  # TODO: pass the Path object instead?
+                 "template": TEMPLATE,
+                 "overwrite": False,
+                 "no_art": False,
+                 "embed_art": False,
+                 "embed_lyrics": False,
+                 "group": False,
+                 "no_slugify": False,
+                 "ok_chars": OK_CHARS,
+                 "space_char": SPACE_CHAR,
+                 "ascii_only": False,
+                 "keep_spaces": False,
+                 "case_mode": CASE_LOWER,
+                 "no_confirm": False,
+                 "debug": False,
+                 "embed_genres": False,
+                 "cover_quality": 0,
+                 "untitled_path_from_slug": False,
+                 "truncate_album": 0,
+                 "truncate_track": 0}
+
+    def __init__(self, dict_=None):
+        if dict_ is None:
+            super().__init__(**Config._defaults)
+        else:
+            super().__init__(**dict_)
+        self.__dict__ = self
+        self._read_write_config()
+
+    def _read_write_config(self):
+        if CONFIG_PATH.exists():
+            with pathlib.Path.open(CONFIG_PATH, 'r+') as fobj:
+                try:
+                    user_config = json.load(fobj)
+                    # change hyphen with underscore
+                    user_config = {k.replace('-', '_'): v for k, v in user_config.items()}
+                    # overwrite defaults with user provided config
+                    if self._update_with_dict(user_config) or \
+                      set(user_config.keys()).difference(set(self.keys())) :
+                        # persist migrated options, removal of unsupported options, or missing
+                        # options with their defaults
+                        sys.stderr.write(f"Modified configuration has been written to "
+                                         f"`{CONFIG_PATH}'.\n")
+                        fobj.seek(0)  # r/w mode
+                        fobj.truncate()  # r/w mode
+                        json.dump({k: v for k, v in self.items()}, fobj)
+                except json.JSONDecodeError:
+                    # NOTE: we don't have logger yet
+                    sys.stderr.write(f"Malformed configuration file `{CONFIG_PATH}'. Check json syntax.\n")
+        else:
+            # No config found - populate it with the defaults
+            os.makedirs(os.path.dirname(CONFIG_PATH), exist_ok=True)
+            with pathlib.Path.open(CONFIG_PATH, mode="w") as fobj:
+                conf = {k.replace('_', '-'): v for k, v in self.items()}
+                json.dump(conf, fobj)
+            sys.stderr.write(f"Configuration has been written to `{CONFIG_PATH}'.\n")
+
+    def _update_with_dict(self, dict_):
+        """update this config instance with the persisted key-value
+           set, migrating or dropping any unknown options and returning
+           true when the underlying config needs updating"""
+        modified = False
+        for key, val in dict_.items():
+            if key not in self:
+                modified = True
+                if not self._migrate_option(key, val):
+                    sys.stderr.write(f"Dropping unknown config option '{key}={val}'\n")
+                continue
+            self[key] = val
+
+    def _migrate_option(self, key, val):
+        """where supported, migrate legacy options and their values
+           to update this config instance's new option, returning
+           true / false to indicate whether or not this key was
+           supported"""
+        migration_type = migration_key = migration_val = None
+        if key == "keep_upper":
+            # forward migration
+            migration_type = OPTION_MIGRATION_FORWARD
+            migration_key = "case_mode"
+            migration_val = self.case_mode = CASE_NONE if val else CASE_LOWER
+        elif key == "case_mode":
+            # reverse migration
+            migration_type = OPTION_MIGRATION_REVERSE
+            migration_key = "keep_upper"
+            migration_val = self.keep_upper = False if val == CASE_LOWER else True
+            if val in [CASE_UPPER, CASE_CAMEL]:
+                sys.stderr.write(f"Warning, lossy reverse migration, new value '{val}' is not backwards compatible\n")
+        if migration_type:
+            sys.stderr.write(f"{migration_type.capitalize()} migration of config option: '{key}={val}' -> " \
+                             f"'{migration_key}={migration_val}'\n")
+            return True
+        else:
+            return False

+ 40 - 0
pyproject.toml

@@ -0,0 +1,40 @@
+[build-system]
+requires = ["setuptools>=69.0.0", "wheel"]
+build-backend = "setuptools.build_meta"
+
+[project]
+name =  "bandcamp-downloader"
+authors = [
+    {name = "Iheanyi Ekechukwu", email = "iekechukwu@gmail.com"},
+    {name = "Anthony Forsberg", email = "forsberganthony@yahoo.com"},
+]
+description = "bandcamp-dl downloads albums and tracks from Bandcamp for you"
+readme = "README.rst"
+classifiers=[
+        'Development Status :: 4 - Beta',
+        'Intended Audience :: End Users/Desktop',
+        'Topic :: Multimedia :: Sound/Audio',
+        'License :: Public Domain',
+        'Programming Language :: Python :: 3.4',
+]
+requires-python = ">=3.4"
+version = "0.0.18"
+dependencies = [
+    "beautifulsoup4 >= 4.13.0b2",
+    "demjson3 >= 3.0.6",
+    "mutagen >= 1.47.0",
+    "requests >= 2.32.3",
+    "unicode-slugify >= 0.1.5",
+    "urllib3 >= 2.2.2",
+    "toml ; python_version < '3.11'"
+]
+
+license = {text = "Unlicense"}
+
+[project.scripts]
+bandcamp-dl = "bandcamp_dl.__main__:main"
+
+[project.urls]
+Documentation = "https://github.com/evolution0/bandcamp-dl/blob/master/README.rst"
+Source = "https://github.com/evolution0/bandcamp-dl"
+Tracker = "https://github.com/evolution0/bandcamp-dl/issues"