Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -171,12 +171,14 @@ python album_browser.py "C:\Users\YourName\Music"
| `--no-bandcamp` | Skip Bandcamp search |
| `--no-qobuz` | Skip Qobuz search |
| `--no-report` | Skip Markdown report generation |
| `--all-album-links` | Fetch and display purchase links for **every** album inline, not just non-lossless ones. No separate shopping list is shown in this mode. |

**Examples:**

```bash
python album_browser.py "C:\Users\User\Music"
python album_browser.py ~/Music --no-bandcamp
python album_browser.py ~/Music --all-album-links
```

---
Expand Down
170 changes: 143 additions & 27 deletions album_browser.py
Original file line number Diff line number Diff line change
Expand Up @@ -222,6 +222,39 @@ def search_qobuz(artist_name: str, album_name: str) -> tuple[str | None, str | N
return (None, None)


def _build_link_suffix(
bc: tuple[str | None, str | None] | None,
qb: tuple[str | None, str | None] | None,
*,
qobuz_supplement_only: bool = False,
) -> str:
"""Returns a formatted purchase-link string to append to an album terminal line.

When qobuz_supplement_only is True (shopping list mode), Qobuz is only appended
when Bandcamp returned an artist-page match or no match at all.
When False (all-album-links mode), Qobuz is appended whenever a URL is available.
"""
suffix = ""
bc_url, bc_match = bc if bc else (None, None)
qb_url = qb[0] if qb else None

if bc_url:
if bc_match == "album":
suffix += f" {Fore.CYAN}🔗 BC: {bc_url}{Style.RESET_ALL}"
else:
suffix += f" {Fore.CYAN}🔗 BC: {bc_url} (artist){Style.RESET_ALL}"
if qobuz_supplement_only and qb_url:
suffix += f" {Fore.GREEN}🔗 Qobuz: {qb_url}{Style.RESET_ALL}"
else:
if qobuz_supplement_only and qb_url:
suffix += f" {Fore.GREEN}🔗 Qobuz: {qb_url}{Style.RESET_ALL}"

if not qobuz_supplement_only and qb_url:
suffix += f" {Fore.GREEN}🔗 Qobuz: {qb_url}{Style.RESET_ALL}"

return suffix


def generate_markdown_report(
script_dir: Path,
music_root: Path,
Expand Down Expand Up @@ -344,14 +377,16 @@ def main():
no_bandcamp = "--no-bandcamp" in args
no_qobuz = "--no-qobuz" in args
no_report = "--no-report" in args
args = [a for a in args if a not in ("--no-bandcamp", "--no-qobuz", "--no-report")]
all_album_links = "--all-album-links" in args
args = [a for a in args if a not in ("--no-bandcamp", "--no-qobuz", "--no-report", "--all-album-links")]

if len(args) < 1:
print(f"{Fore.RED}Usage: python album_browser.py <music_folder_path> [--no-bandcamp] [--no-qobuz] [--no-report]{Style.RESET_ALL}")
print(f"{Fore.RED}Usage: python album_browser.py <music_folder_path> [--no-bandcamp] [--no-qobuz] [--no-report] [--all-album-links]{Style.RESET_ALL}")
print(f"Example: python album_browser.py \"C:\\Users\\User\\Music\"")
print(f" --no-bandcamp Skip Bandcamp search")
print(f" --no-qobuz Skip Qobuz search")
print(f" --no-report Skip MD report generation")
print(f" --no-bandcamp Skip Bandcamp search")
print(f" --no-qobuz Skip Qobuz search")
print(f" --no-report Skip MD report generation")
print(f" --all-album-links Fetch and show links for every album inline (not just non-lossless)")
return

music_root = Path(args[0])
Expand Down Expand Up @@ -390,7 +425,8 @@ def main():

total_artists += 1
current_artist = {"name": artist_name, "albums": []}
print(f" {Fore.YELLOW}🎤 {artist_name}{Style.RESET_ALL}")
if not all_album_links:
print(f" {Fore.YELLOW}🎤 {artist_name}{Style.RESET_ALL}")

for album_dir in album_dirs:
album_name = album_dir.name
Expand Down Expand Up @@ -441,7 +477,8 @@ def main():
)
if not all_lossless:
line += f" {Fore.RED}⚠️ Non-lossless: {', '.join(lossy_fmts)}{Style.RESET_ALL}"
print(line)
if not all_album_links:
print(line)

# Loose tracks (directly in the artist folder)
if loose_files:
Expand Down Expand Up @@ -480,10 +517,97 @@ def main():
)
if not all_lossless:
line += f" {Fore.RED}⚠️ Non-lossless: {', '.join(lossy_fmts)}{Style.RESET_ALL}"
print(line)
if not all_album_links:
print(line)

artists_data.append(current_artist)
if not all_album_links:
print()

# --all-album-links: fetch links for every album then print the full tree with inline links
if all_album_links:
all_links: dict[tuple[str, str], dict] = {}
all_album_entries: list[tuple[str, str]] = []
for artist_info in artists_data:
for album in artist_info["albums"]:

Copilot AI Mar 6, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

In --all-album-links mode, all_album_entries is built from every entry in artist_info["albums"], which includes the pseudo-album (Loose tracks) (see where is_loose is set). That will trigger external Bandcamp/Qobuz searches for (Loose tracks), which is not a real album and can waste requests / slow down scans. Consider filtering out is_loose entries (or names like (Loose tracks)) when building all_album_entries and when appending link suffixes.

Suggested change
for album in artist_info["albums"]:
for album in artist_info["albums"]:
# Skip pseudo-album used for loose tracks to avoid useless external lookups
if album.get("is_loose") or album.get("name") == "(Loose tracks)":
continue

Copilot uses AI. Check for mistakes.
all_album_entries.append((artist_info["name"], album["name"]))

if all_album_entries and not (no_bandcamp and no_qobuz):
print()
print(f" {Fore.CYAN}🔍 Fetching links for all albums...{Style.RESET_ALL}")
for i, (artist, album) in enumerate(all_album_entries, 1):
print(
f" {Fore.CYAN} ({i}/{len(all_album_entries)}) {artist} – {album}...{Style.RESET_ALL}",
end="", flush=True,
)

found = False
bc_artist_only = False
all_links[(artist, album)] = {"bc": (None, None), "qb": (None, None)}

if not no_bandcamp:
url, match_type = search_bandcamp(artist, album)
all_links[(artist, album)]["bc"] = (url, match_type)
if url:
if match_type == "album":
print(f" {Fore.GREEN}💿 BC found!{Style.RESET_ALL}")
found = True
else:
print(f" {Fore.GREEN}🎤 BC artist{Style.RESET_ALL}", end="", flush=True)
bc_artist_only = True

if (not found or bc_artist_only) and not no_qobuz:
if not no_bandcamp:
time.sleep(RATE_LIMIT)
url, match_type = search_qobuz(artist, album)
all_links[(artist, album)]["qb"] = (url, match_type)
Comment on lines +559 to +563

Copilot AI Mar 6, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

In --all-album-links mode, Qobuz is only searched as a fallback when Bandcamp didn’t return an album match (if (not found or bc_artist_only) ...). This means albums with a confirmed Bandcamp album URL will never have a Qobuz URL fetched/shown inline, which conflicts with the PR description claim that all-album-links mode “shows Qobuz whenever found”. Either update the implementation to also query Qobuz even when Bandcamp finds an album (opt-in cost: more requests), or adjust the PR/docs to reflect fallback-only behavior.

Copilot uses AI. Check for mistakes.
if url:
print(f" {Fore.GREEN}💿 Qobuz found!{Style.RESET_ALL}")
found = True
elif bc_artist_only:
print()
found = True
elif bc_artist_only:
print()
found = True

if not found:
print(f" {Fore.RED}❌{Style.RESET_ALL}")

if i < len(all_album_entries):
time.sleep(RATE_LIMIT)

# Print full tree with inline links
print()
for artist_info in artists_data:
artist_name = artist_info["name"]
print(f" {Fore.YELLOW}🎤 {artist_name}{Style.RESET_ALL}")
for album in artist_info["albums"]:
name = album["name"]
format_str = album["format_str"]
track_count = album["track_count"]
size_str = album["size_str"]
all_lossless = album["all_lossless"]
lossy_fmts = album["lossy_fmts"]
is_loose = album.get("is_loose", False)

format_color = Fore.GREEN if all_lossless else Fore.MAGENTA
icon = "📁" if is_loose else "💿"
name_color = Fore.LIGHTYELLOW_EX if is_loose else Fore.WHITE

line = (
f" ├── {icon} {name_color}{name}{Style.RESET_ALL}"
f" [{format_color}{format_str}{Style.RESET_ALL}]"
f" ({track_count} tracks, {size_str})"
)
if not all_lossless:
line += f" {Fore.RED}⚠️ Non-lossless: {', '.join(lossy_fmts)}{Style.RESET_ALL}"

links = all_links.get((artist_name, name))
if links:
line += _build_link_suffix(links["bc"], links["qb"])
print(line)
print()

# Summary
print(f"{Fore.CYAN}╔══════════════════════════════════════════════════════════════╗")
Expand All @@ -496,10 +620,15 @@ def main():
print(f"{Fore.CYAN}╚══════════════════════════════════════════════════════════════╝{Style.RESET_ALL}")

# Shopping list
if shopping_list:
bandcamp_results: dict = {}
qobuz_results: dict = {}

if all_album_links:
# Links were already fetched for every album; derive report-compatible dicts
bandcamp_results = {k: v["bc"] for k, v in all_links.items()}
qobuz_results = {k: v["qb"] for k, v in all_links.items()}
elif shopping_list:
# Fetch purchase links for shopping list albums
bandcamp_results = {}
qobuz_results = {}
if not no_bandcamp or not no_qobuz:
print()
print(f" {Fore.CYAN}🔍 Searching for purchase links...{Style.RESET_ALL}")
Expand Down Expand Up @@ -559,20 +688,7 @@ def main():
# Add purchase links
bc = bandcamp_results.get((artist, album))
qb = qobuz_results.get((artist, album))
if bc and bc[0]:
url, match_type = bc
if match_type == "album":
line += f" {Fore.CYAN}🔗 BC: {url}{Style.RESET_ALL}"
else:
line += f" {Fore.CYAN}🔗 BC: {url} (artist){Style.RESET_ALL}"
# Supplement with Qobuz if available
if qb and qb[0]:
line += f" {Fore.GREEN}🔗 Qobuz: {qb[0]}{Style.RESET_ALL}"
else:
# Qobuz-only fallback
if qb and qb[0]:
url, _ = qb
line += f" {Fore.GREEN}🔗 Qobuz: {url}{Style.RESET_ALL}"
line += _build_link_suffix(bc, qb, qobuz_supplement_only=True)

print(line)
print(f"{Fore.RED}╚══════════════════════════════════════════════════════════════╝{Style.RESET_ALL}")
Expand All @@ -599,8 +715,8 @@ def main():
total_lossless=total_lossless,
total_non_lossless=total_non_lossless,
shopping_list=shopping_list,
bandcamp_results=bandcamp_results if shopping_list else {},
qobuz_results=qobuz_results if shopping_list else {},
bandcamp_results=bandcamp_results,
qobuz_results=qobuz_results,
)
print(f"\n {Fore.CYAN}📝 Report saved: {md_path}{Style.RESET_ALL}")

Expand Down
115 changes: 114 additions & 1 deletion test_album_browser.py
Original file line number Diff line number Diff line change
@@ -1,9 +1,12 @@
"""Tests for album_browser.py — focused on Bandcamp search accuracy improvements."""
import difflib
import sys
import tempfile
import unittest
from pathlib import Path
from unittest.mock import patch

from album_browser import _is_similar, _normalize, search_bandcamp
from album_browser import _is_similar, _normalize, search_bandcamp, main


class TestNormalize(unittest.TestCase):
Expand Down Expand Up @@ -197,3 +200,113 @@ def test_the_article_artist_variation(self, mock_search, mock_sleep):

if __name__ == "__main__":
unittest.main()


Comment on lines 201 to +204

Copilot AI Mar 6, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

unittest.main() is invoked before the newly added integration tests are defined, so running this file directly (python test_album_browser.py) will exit before TestAllAlbumLinksFlag is registered/executed. Move the if __name__ == "__main__": unittest.main() block to the very end of the file (or remove it and rely on unittest discovery) so the new tests run in all invocation modes.

Suggested change
if __name__ == "__main__":
unittest.main()

Copilot uses AI. Check for mistakes.
# ---------------------------------------------------------------------------
# Helpers for integration-style main() tests
# ---------------------------------------------------------------------------

def _make_music_dir(base: Path) -> Path:
"""Creates a small mock music directory tree and returns its path.

Structure:
Artist A/
Lossless Album/ — 2 FLAC files
Lossy Album/ — 1 MP3 file
Artist B/
Only Lossless/ — 1 FLAC file
"""
(base / "Artist A" / "Lossless Album").mkdir(parents=True)
(base / "Artist A" / "Lossy Album").mkdir(parents=True)
(base / "Artist B" / "Only Lossless").mkdir(parents=True)

(base / "Artist A" / "Lossless Album" / "01.flac").write_bytes(b"\x00" * 1024)
(base / "Artist A" / "Lossless Album" / "02.flac").write_bytes(b"\x00" * 1024)
(base / "Artist A" / "Lossy Album" / "01.mp3").write_bytes(b"\x00" * 512)
(base / "Artist B" / "Only Lossless" / "01.flac").write_bytes(b"\x00" * 2048)

return base


class TestAllAlbumLinksFlag(unittest.TestCase):
"""Integration tests for the --all-album-links switch."""

@staticmethod
def _run_main(music_dir: Path, extra_args: list[str]) -> str:
"""Runs main() with patched sys.argv and returns captured stdout."""
import io

argv = ["album_browser.py", str(music_dir)] + extra_args
with patch.object(sys, "argv", argv), \
patch("sys.stdout", new_callable=io.StringIO) as mock_out, \
patch("album_browser.search_bandcamp", return_value=(None, None)), \
patch("album_browser.search_qobuz", return_value=(None, None)), \
patch("album_browser.time.sleep"):
main()
return mock_out.getvalue()

def test_all_album_links_fetches_for_every_album(self):
"""With --all-album-links, search_bandcamp is called for ALL albums."""
with tempfile.TemporaryDirectory() as tmp:
music_dir = _make_music_dir(Path(tmp))
argv = ["album_browser.py", str(music_dir), "--all-album-links",
"--no-qobuz", "--no-report"]
with patch.object(sys, "argv", argv), \
patch("album_browser.search_bandcamp", return_value=(None, None)) as mock_bc, \
patch("album_browser.search_qobuz", return_value=(None, None)), \
patch("album_browser.time.sleep"):
main()
# 3 albums total: Lossless Album, Lossy Album, Only Lossless
self.assertEqual(mock_bc.call_count, 3)

def test_default_mode_only_fetches_for_lossy_albums(self):
"""Without --all-album-links, search_bandcamp is called only for lossy albums."""
with tempfile.TemporaryDirectory() as tmp:
music_dir = _make_music_dir(Path(tmp))
argv = ["album_browser.py", str(music_dir), "--no-qobuz", "--no-report"]
with patch.object(sys, "argv", argv), \
patch("album_browser.search_bandcamp", return_value=(None, None)) as mock_bc, \
patch("album_browser.search_qobuz", return_value=(None, None)), \
patch("album_browser.time.sleep"):
main()
# Only 1 lossy album (Lossy Album by Artist A)
self.assertEqual(mock_bc.call_count, 1)

def test_all_album_links_no_shopping_list_box(self):
"""With --all-album-links, the SHOPPING LIST box must NOT appear."""
with tempfile.TemporaryDirectory() as tmp:
music_dir = _make_music_dir(Path(tmp))
output = self._run_main(music_dir, ["--all-album-links", "--no-report"])
self.assertNotIn("SHOPPING LIST", output)

def test_default_mode_shows_shopping_list_box(self):
"""Without --all-album-links, the SHOPPING LIST box IS shown for lossy albums."""
with tempfile.TemporaryDirectory() as tmp:
music_dir = _make_music_dir(Path(tmp))
output = self._run_main(music_dir, ["--no-report"])
self.assertIn("SHOPPING LIST", output)

def test_all_album_links_inline_link_in_tree(self):
"""With --all-album-links and a BC match, the link appears in the album tree."""
with tempfile.TemporaryDirectory() as tmp:
music_dir = _make_music_dir(Path(tmp))
argv = ["album_browser.py", str(music_dir), "--all-album-links",
"--no-qobuz", "--no-report"]
fake_url = "https://artistb.bandcamp.com/album/only-lossless"
import io

def _bc_side_effect(artist, album):
if artist == "Artist B" and album == "Only Lossless":
return (fake_url, "album")
return (None, None)

with patch.object(sys, "argv", argv), \
patch("sys.stdout", new_callable=io.StringIO) as mock_out, \
patch("album_browser.search_bandcamp", side_effect=_bc_side_effect), \
patch("album_browser.search_qobuz", return_value=(None, None)), \
patch("album_browser.time.sleep"):
main()
output = mock_out.getvalue()
# The link must appear in the tree output, not in a separate section
self.assertIn(fake_url, output)
self.assertNotIn("SHOPPING LIST", output)