From 4af3ec3b298cddae3aba772a0d5e2e12f8617a50 Mon Sep 17 00:00:00 2001 From: MatMacinf Date: Mon, 5 Jan 2026 11:07:54 +0100 Subject: [PATCH 01/12] Added new plugin with tests --- beets/random.py | 112 -------------------------------- beetsplug/report.py | 153 ++++++++++++++++++++++++++++++++++++++++++++ test/test_report.py | 119 ++++++++++++++++++++++++++++++++++ 3 files changed, 272 insertions(+), 112 deletions(-) delete mode 100644 beets/random.py create mode 100644 beetsplug/report.py create mode 100644 test/test_report.py diff --git a/beets/random.py b/beets/random.py deleted file mode 100644 index f3318054c..000000000 --- a/beets/random.py +++ /dev/null @@ -1,112 +0,0 @@ -# This file is part of beets. -# Copyright 2016, Philippe Mongeau. -# -# Permission is hereby granted, free of charge, to any person obtaining -# a copy of this software and associated documentation files (the -# "Software"), to deal in the Software without restriction, including -# without limitation the rights to use, copy, modify, merge, publish, -# distribute, sublicense, and/or sell copies of the Software, and to -# permit persons to whom the Software is furnished to do so, subject to -# the following conditions: -# -# The above copyright notice and this permission notice shall be -# included in all copies or substantial portions of the Software. - -"""Get a random song or album from the library.""" - -import random -from itertools import groupby -from operator import attrgetter - - -def _length(obj, album): - """Get the duration of an item or album.""" - if album: - return sum(i.length for i in obj.items()) - else: - return obj.length - - -def _equal_chance_permutation(objs, field="albumartist", random_gen=None): - """Generate (lazily) a permutation of the objects where every group - with equal values for `field` have an equal chance of appearing in - any given position. - """ - rand = random_gen or random - - # Group the objects by artist so we can sample from them. - key = attrgetter(field) - objs.sort(key=key) - objs_by_artists = {} - for artist, v in groupby(objs, key): - objs_by_artists[artist] = list(v) - - # While we still have artists with music to choose from, pick one - # randomly and pick a track from that artist. - while objs_by_artists: - # Choose an artist and an object for that artist, removing - # this choice from the pool. - artist = rand.choice(list(objs_by_artists.keys())) - objs_from_artist = objs_by_artists[artist] - i = rand.randint(0, len(objs_from_artist) - 1) - yield objs_from_artist.pop(i) - - # Remove the artist if we've used up all of its objects. - if not objs_from_artist: - del objs_by_artists[artist] - - -def _take(iter, num): - """Return a list containing the first `num` values in `iter` (or - fewer, if the iterable ends early). - """ - out = [] - for val in iter: - out.append(val) - num -= 1 - if num <= 0: - break - return out - - -def _take_time(iter, secs, album): - """Return a list containing the first values in `iter`, which should - be Item or Album objects, that add up to the given amount of time in - seconds. - """ - out = [] - total_time = 0.0 - for obj in iter: - length = _length(obj, album) - if total_time + length <= secs: - out.append(obj) - total_time += length - return out - - -def random_objs( - objs, album, number=1, time=None, equal_chance=False, random_gen=None -): - """Get a random subset of the provided `objs`. - - If `number` is provided, produce that many matches. Otherwise, if - `time` is provided, instead select a list whose total time is close - to that number of minutes. If `equal_chance` is true, give each - artist an equal chance of being included so that artists with more - songs are not represented disproportionately. - """ - rand = random_gen or random - - # Permute the objects either in a straightforward way or an - # artist-balanced way. - if equal_chance: - perm = _equal_chance_permutation(objs) - else: - perm = objs - rand.shuffle(perm) # N.B. This shuffles the original list. - - # Select objects by time our count. - if time: - return _take_time(perm, time * 60, album) - else: - return _take(perm, number) diff --git a/beetsplug/report.py b/beetsplug/report.py new file mode 100644 index 000000000..13ab91375 --- /dev/null +++ b/beetsplug/report.py @@ -0,0 +1,153 @@ +# This file is part of beets. +# +# Permission is hereby granted, free of charge, to any person obtaining +# a copy of this software and associated documentation files (the +# "Software"), to deal in the Software without restriction, including +# without limitation the rights to use, copy, modify, merge, publish, +# distribute, sublicense, and/or sell copies of the Software, and to +# permit persons to whom the Software is furnished to do so, subject to +# the following conditions: +# +# The above copyright notice and this permission notice shall be +# included in all copies or substantial portions of the Software. + +"""Report plugin for Beets: generate statistical summaries of your music library.""" + +import datetime +from collections import Counter + +from beets.plugins import BeetsPlugin +from beets.ui import Subcommand, print_ + + +class ReportPlugin(BeetsPlugin): + """A Beets plugin that generates a library report with statistics and Wrapped-style insights.""" + + def commands(self): + report_cmd = Subcommand( + "report", + help="Generate a statistical report of your music library." + ) + report_cmd.func = self._run_report + return [report_cmd] + + def _run_report(self, lib, opts, args): + """Collect statistics and print a report about the library.""" + items = list(lib.items()) + total_tracks = len(items) + + if total_tracks == 0: + print_("Your Beets library is empty.") + return + + # --- Collect metadata --- + artists = [i.artist for i in items if i.artist] + albums = [i.album for i in items if i.album] + genres = [i.genre for i in items if i.genre] + years = [i.year for i in items if isinstance(i.year, int) and i.year > 0] + formats = [i.format for i in items if i.format] + lengths = [i.length for i in items if i.length] + bitrates = [i.bitrate for i in items if i.bitrate] + + # --- Counters --- + artist_counter = Counter(artists) + genre_counter = Counter(genres) + format_counter = Counter(formats) + year_counter = Counter(years) + + # --- Time calculations --- + total_length = sum(lengths) if lengths else 0 + avg_length = total_length / len(lengths) if lengths else 0 + + def fmt_time(seconds): + return str(datetime.timedelta(seconds=int(seconds))) + + # --- Decades --- + current_year = datetime.datetime.now().year + decades = [(y // 10) * 10 for y in years if 1900 <= y <= current_year] + decade_counter = Counter(decades) + + def decade_label(d): + return f"{str(d)[-2:]}s" + + # --- Wrapped insights --- + top_artist = artist_counter.most_common(1) + top_genre = genre_counter.most_common(1) + top_decade = decade_counter.most_common(1) + top_year = year_counter.most_common(1) + + longest_track = max(items, key=lambda i: i.length or 0) + shortest_track = min((i for i in items if i.length), key=lambda i: i.length, default=None) + + missing_genre = sum(1 for i in items if not i.genre) + missing_year = sum(1 for i in items if not isinstance(i.year, int) or i.year <= 0) + + recent_tracks = sum(1 for y in years if y >= 2015) + older_tracks = len(years) - recent_tracks + + avg_bitrate = sum(bitrates) // len(bitrates) if bitrates else None + quality = ( + "Hi-Fi" if avg_bitrate and avg_bitrate >= 900 else + "High quality" if avg_bitrate and avg_bitrate >= 320 else + "Standard quality" + ) + + # ===================== REPORT ===================== + print_("Beets Library Report") + print_(f"Generated: {datetime.datetime.now():%Y-%m-%d %H:%M:%S}") + print_("=" * 60) + + # --- Overview --- + print_("Overview") + print_(f" Tracks: {total_tracks}") + print_(f" Albums: {len(set(albums))}") + print_(f" Artists: {len(set(artists))}") + print_(f" Genres: {len(set(genres))}") + print_(f" Years: {min(years)} – {max(years)}" if years else " Years: n/a") + print_("-" * 60) + + # --- Duration & quality --- + print_("Listening time & quality") + print_(f" Total playtime: {fmt_time(total_length)}") + print_(f" Avg track length: {fmt_time(avg_length)}") + if avg_bitrate: + print_(f" Avg bitrate: {avg_bitrate} kbps ({quality})") + if format_counter: + print_(f" Primary format: {format_counter.most_common(1)[0][0]}") + print_("-" * 60) + + # --- Decade distribution --- + print_("Favorite musical decades") + if decade_counter: + total_decade_tracks = sum(decade_counter.values()) + for d, c in decade_counter.most_common(): + pct = (c / total_decade_tracks) * 100 + print_(f" {decade_label(d):>4} ({d}-{d+9}): {c:>5} tracks ({pct:4.1f}%)") + else: + print_(" n/a") + print_("-" * 60) + + # --- Wrapped summary --- + print_("Your Music Wrapped") + if top_artist: + print_(f" Top artist: {top_artist[0][0]} ({top_artist[0][1]} tracks)") + if top_genre: + print_(f" Top genre: {top_genre[0][0]} ({top_genre[0][1]} tracks)") + if top_decade: + d, c = top_decade[0] + print_(f" Top decade: {decade_label(d)} ({d}-{d+9}, {c} tracks)") + if top_year: + y, c = top_year[0] + print_(f" Top year: {y} ({c} tracks)") + + print_(f" Longest track: {longest_track.artist} – {longest_track.title} ({fmt_time(longest_track.length)})") + if shortest_track: + print_(f" Shortest track: {shortest_track.artist} – {shortest_track.title} ({fmt_time(shortest_track.length)})") + + print_(f" New music (2015+): {recent_tracks}") + print_(f" Older music: {older_tracks}") + + print_(f" Missing genre tags: {missing_genre}") + print_(f" Missing year tags: {missing_year}") + + print_("\nReport complete.") \ No newline at end of file diff --git a/test/test_report.py b/test/test_report.py new file mode 100644 index 000000000..a99fa5c0e --- /dev/null +++ b/test/test_report.py @@ -0,0 +1,119 @@ +import pytest +from beets.library import Item +from beetsplug.report_plugin import ReportPlugin + +# --- Fixtures --- + +@pytest.fixture +def library(tmp_path): + """Create a temporary empty Beets library.""" + from beets.library import Library + lib_path = tmp_path / "beets.db" + lib = Library(str(lib_path)) + return lib + +def add_item(lib, title="Test", artist="Artist", album="Album", genre="Genre", + year=2000, length=180, bitrate=320): + """Add a single Item to the test library.""" + item = Item( + path=f"/tmp/{title}.mp3", + title=title, + artist=artist, + album=album, + genre=genre, + year=year, + length=length, + bitrate=bitrate + ) + lib.add(item) + +# --- Tests --- + +def test_empty_library(capsys, library): + """Test empty library: should output message without crashing.""" + plugin = ReportPlugin() + plugin._run_report(library, None, []) + captured = capsys.readouterr() + assert "Your Beets library is empty." in captured.out + +def test_single_item(capsys, library): + """Test library with a single track.""" + add_item(library, title="Single Track", artist="Solo Artist", genre="Indie", year=2019) + plugin = ReportPlugin() + plugin._run_report(library, None, []) + captured = capsys.readouterr() + + # --- Check basic statistics --- + assert "Tracks: 1" in captured.out + assert "Albums: 1" in captured.out + assert "Artists: 1" in captured.out + assert "Genres: 1" in captured.out + + # --- Wrapped-style insights --- + assert "Top artist: Solo Artist (1 tracks)" in captured.out + assert "Top genre: Indie (1 tracks)" in captured.out + assert "Top decade: 10s (2010-2019, 1 tracks)" in captured.out + assert "Top year: 2019 (1 tracks)" in captured.out + +def test_multiple_items(capsys, library): + """Test library with multiple tracks from different decades and genres.""" + # 1995 – 2 tracks Rock + add_item(library, "Track1", "Artist A", "Album X", "Rock", 1995) + add_item(library, "Track2", "Artist A", "Album X", "Rock", 1995) + + # 2002 – 1 track Pop + add_item(library, "Track3", "Artist B", "Album Y", "Pop", 2002) + + # 2018 – 1 track Electronic + add_item(library, "Track4", "Artist C", "Album Z", "Electronic", 2018) + + plugin = ReportPlugin() + plugin._run_report(library, None, []) + captured = capsys.readouterr() + + # --- Basic stats --- + assert "Tracks: 4" in captured.out + assert "Albums: 3" in captured.out + assert "Artists: 3" in captured.out + assert "Genres: 3" in captured.out + + # --- Wrapped insights --- + assert "Top artist: Artist A (2 tracks)" in captured.out + assert "Top genre: Rock (2 tracks)" in captured.out + assert "Top decade: 90s (1990-1999, 2 tracks)" in captured.out + assert "Top year: 1995 (2 tracks)" in captured.out + + # --- Decade distribution --- + assert "90s (1990-1999: 2 tracks" in captured.out + assert "00s (2000-2009: 1 tracks" in captured.out + assert "10s (2010-2019: 1 tracks" in captured.out + +def test_missing_metadata(capsys, library): + """Test library with missing tags, length, and bitrate.""" + # Missing genre + add_item(library, "Track1", "Artist", "Album", None, 2000, length=200, bitrate=256) + # Missing year + add_item(library, "Track2", "Artist", "Album", "Rock", None, length=180, bitrate=None) + + plugin = ReportPlugin() + plugin._run_report(library, None, []) + captured = capsys.readouterr() + + assert "Missing genre tags: 1" in captured.out + assert "Missing year tags: 1" in captured.out + +def test_various_lengths_and_bitrates(capsys, library): + """Test track lengths and bitrate classification with different values.""" + add_item(library, "Short", "A", "X", "Pop", 2010, length=60, bitrate=128) + add_item(library, "Long", "B", "Y", "Rock", 2015, length=3600, bitrate=1024) + + plugin = ReportPlugin() + plugin._run_report(library, None, []) + captured = capsys.readouterr() + + # --- Check durations --- + assert "Total playtime: 1:01:00" in captured.out + assert "Avg track length: 0:31:30" in captured.out + + # --- Check bitrate and quality classification --- + assert "Avg bitrate: 576 kbps (High quality)" in captured.out From 3ad21629ae5070fca6a5e95d460ccf7b4bbd268e Mon Sep 17 00:00:00 2001 From: MatMacinf Date: Mon, 5 Jan 2026 11:52:59 +0100 Subject: [PATCH 02/12] Add report plugin with test and documentation --- beets/.zshrc | 16 +++++ beets/random_utils.py | 112 ++++++++++++++++++++++++++++++++++ beetsplug/report.py | 58 +++++++++++++----- docs/changelog.rst | 4 ++ docs/plugins/report.rst | 62 +++++++++++++++++++ test/test_report.py | 130 ++++++++++++++++++++++++---------------- 6 files changed, 313 insertions(+), 69 deletions(-) create mode 100644 beets/.zshrc create mode 100644 beets/random_utils.py create mode 100644 docs/plugins/report.rst diff --git a/beets/.zshrc b/beets/.zshrc new file mode 100644 index 000000000..1bce84375 --- /dev/null +++ b/beets/.zshrc @@ -0,0 +1,16 @@ + +# >>> conda initialize >>> +# !! Contents within this block are managed by 'conda init' !! +__conda_setup="$('/opt/anaconda3/bin/conda' 'shell.zsh' 'hook' 2> /dev/null)" +if [ $? -eq 0 ]; then + eval "$__conda_setup" +else + if [ -f "/opt/anaconda3/etc/profile.d/conda.sh" ]; then + . "/opt/anaconda3/etc/profile.d/conda.sh" + else + export PATH="/opt/anaconda3/bin:$PATH" + fi +fi +unset __conda_setup +# <<< conda initialize <<< +export PATH="$HOME/.local/bin:$PATH" diff --git a/beets/random_utils.py b/beets/random_utils.py new file mode 100644 index 000000000..f3318054c --- /dev/null +++ b/beets/random_utils.py @@ -0,0 +1,112 @@ +# This file is part of beets. +# Copyright 2016, Philippe Mongeau. +# +# Permission is hereby granted, free of charge, to any person obtaining +# a copy of this software and associated documentation files (the +# "Software"), to deal in the Software without restriction, including +# without limitation the rights to use, copy, modify, merge, publish, +# distribute, sublicense, and/or sell copies of the Software, and to +# permit persons to whom the Software is furnished to do so, subject to +# the following conditions: +# +# The above copyright notice and this permission notice shall be +# included in all copies or substantial portions of the Software. + +"""Get a random song or album from the library.""" + +import random +from itertools import groupby +from operator import attrgetter + + +def _length(obj, album): + """Get the duration of an item or album.""" + if album: + return sum(i.length for i in obj.items()) + else: + return obj.length + + +def _equal_chance_permutation(objs, field="albumartist", random_gen=None): + """Generate (lazily) a permutation of the objects where every group + with equal values for `field` have an equal chance of appearing in + any given position. + """ + rand = random_gen or random + + # Group the objects by artist so we can sample from them. + key = attrgetter(field) + objs.sort(key=key) + objs_by_artists = {} + for artist, v in groupby(objs, key): + objs_by_artists[artist] = list(v) + + # While we still have artists with music to choose from, pick one + # randomly and pick a track from that artist. + while objs_by_artists: + # Choose an artist and an object for that artist, removing + # this choice from the pool. + artist = rand.choice(list(objs_by_artists.keys())) + objs_from_artist = objs_by_artists[artist] + i = rand.randint(0, len(objs_from_artist) - 1) + yield objs_from_artist.pop(i) + + # Remove the artist if we've used up all of its objects. + if not objs_from_artist: + del objs_by_artists[artist] + + +def _take(iter, num): + """Return a list containing the first `num` values in `iter` (or + fewer, if the iterable ends early). + """ + out = [] + for val in iter: + out.append(val) + num -= 1 + if num <= 0: + break + return out + + +def _take_time(iter, secs, album): + """Return a list containing the first values in `iter`, which should + be Item or Album objects, that add up to the given amount of time in + seconds. + """ + out = [] + total_time = 0.0 + for obj in iter: + length = _length(obj, album) + if total_time + length <= secs: + out.append(obj) + total_time += length + return out + + +def random_objs( + objs, album, number=1, time=None, equal_chance=False, random_gen=None +): + """Get a random subset of the provided `objs`. + + If `number` is provided, produce that many matches. Otherwise, if + `time` is provided, instead select a list whose total time is close + to that number of minutes. If `equal_chance` is true, give each + artist an equal chance of being included so that artists with more + songs are not represented disproportionately. + """ + rand = random_gen or random + + # Permute the objects either in a straightforward way or an + # artist-balanced way. + if equal_chance: + perm = _equal_chance_permutation(objs) + else: + perm = objs + rand.shuffle(perm) # N.B. This shuffles the original list. + + # Select objects by time our count. + if time: + return _take_time(perm, time * 60, album) + else: + return _take(perm, number) diff --git a/beetsplug/report.py b/beetsplug/report.py index 13ab91375..9466c76a8 100644 --- a/beetsplug/report.py +++ b/beetsplug/report.py @@ -26,7 +26,7 @@ class ReportPlugin(BeetsPlugin): def commands(self): report_cmd = Subcommand( "report", - help="Generate a statistical report of your music library." + help="Generate a statistical report of your music library.", ) report_cmd.func = self._run_report return [report_cmd] @@ -44,7 +44,9 @@ class ReportPlugin(BeetsPlugin): artists = [i.artist for i in items if i.artist] albums = [i.album for i in items if i.album] genres = [i.genre for i in items if i.genre] - years = [i.year for i in items if isinstance(i.year, int) and i.year > 0] + years = [ + i.year for i in items if isinstance(i.year, int) and i.year > 0 + ] formats = [i.format for i in items if i.format] lengths = [i.length for i in items if i.length] bitrates = [i.bitrate for i in items if i.bitrate] @@ -77,19 +79,25 @@ class ReportPlugin(BeetsPlugin): top_year = year_counter.most_common(1) longest_track = max(items, key=lambda i: i.length or 0) - shortest_track = min((i for i in items if i.length), key=lambda i: i.length, default=None) + shortest_track = min( + (i for i in items if i.length), key=lambda i: i.length, default=None + ) missing_genre = sum(1 for i in items if not i.genre) - missing_year = sum(1 for i in items if not isinstance(i.year, int) or i.year <= 0) + missing_year = sum( + 1 for i in items if not isinstance(i.year, int) or i.year <= 0 + ) recent_tracks = sum(1 for y in years if y >= 2015) older_tracks = len(years) - recent_tracks avg_bitrate = sum(bitrates) // len(bitrates) if bitrates else None quality = ( - "Hi-Fi" if avg_bitrate and avg_bitrate >= 900 else - "High quality" if avg_bitrate and avg_bitrate >= 320 else - "Standard quality" + "Hi-Fi" + if avg_bitrate and avg_bitrate >= 900 + else "High quality" + if avg_bitrate and avg_bitrate >= 320 + else "Standard quality" ) # ===================== REPORT ===================== @@ -103,7 +111,11 @@ class ReportPlugin(BeetsPlugin): print_(f" Albums: {len(set(albums))}") print_(f" Artists: {len(set(artists))}") print_(f" Genres: {len(set(genres))}") - print_(f" Years: {min(years)} – {max(years)}" if years else " Years: n/a") + print_( + f" Years: {min(years)} – {max(years)}" + if years + else " Years: n/a" + ) print_("-" * 60) # --- Duration & quality --- @@ -113,7 +125,9 @@ class ReportPlugin(BeetsPlugin): if avg_bitrate: print_(f" Avg bitrate: {avg_bitrate} kbps ({quality})") if format_counter: - print_(f" Primary format: {format_counter.most_common(1)[0][0]}") + print_( + f" Primary format: {format_counter.most_common(1)[0][0]}" + ) print_("-" * 60) # --- Decade distribution --- @@ -122,7 +136,9 @@ class ReportPlugin(BeetsPlugin): total_decade_tracks = sum(decade_counter.values()) for d, c in decade_counter.most_common(): pct = (c / total_decade_tracks) * 100 - print_(f" {decade_label(d):>4} ({d}-{d+9}): {c:>5} tracks ({pct:4.1f}%)") + print_( + f" {decade_label(d):>4} ({d}-{d + 9}): {c:>5} tracks ({pct:4.1f}%)" + ) else: print_(" n/a") print_("-" * 60) @@ -130,19 +146,29 @@ class ReportPlugin(BeetsPlugin): # --- Wrapped summary --- print_("Your Music Wrapped") if top_artist: - print_(f" Top artist: {top_artist[0][0]} ({top_artist[0][1]} tracks)") + print_( + f" Top artist: {top_artist[0][0]} ({top_artist[0][1]} tracks)" + ) if top_genre: - print_(f" Top genre: {top_genre[0][0]} ({top_genre[0][1]} tracks)") + print_( + f" Top genre: {top_genre[0][0]} ({top_genre[0][1]} tracks)" + ) if top_decade: d, c = top_decade[0] - print_(f" Top decade: {decade_label(d)} ({d}-{d+9}, {c} tracks)") + print_( + f" Top decade: {decade_label(d)} ({d}-{d + 9}, {c} tracks)" + ) if top_year: y, c = top_year[0] print_(f" Top year: {y} ({c} tracks)") - print_(f" Longest track: {longest_track.artist} – {longest_track.title} ({fmt_time(longest_track.length)})") + print_( + f" Longest track: {longest_track.artist} – {longest_track.title} ({fmt_time(longest_track.length)})" + ) if shortest_track: - print_(f" Shortest track: {shortest_track.artist} – {shortest_track.title} ({fmt_time(shortest_track.length)})") + print_( + f" Shortest track: {shortest_track.artist} – {shortest_track.title} ({fmt_time(shortest_track.length)})" + ) print_(f" New music (2015+): {recent_tracks}") print_(f" Older music: {older_tracks}") @@ -150,4 +176,4 @@ class ReportPlugin(BeetsPlugin): print_(f" Missing genre tags: {missing_genre}") print_(f" Missing year tags: {missing_year}") - print_("\nReport complete.") \ No newline at end of file + print_("\nReport complete.") diff --git a/docs/changelog.rst b/docs/changelog.rst index b9e21aae9..09a6bd19e 100644 --- a/docs/changelog.rst +++ b/docs/changelog.rst @@ -12,6 +12,10 @@ been dropped. New features: +- :doc:`plugins/report`: Added `report` plugin to generate a statistical summary + of your music library, including tracks, albums, artists, genres, years, + listening time, audio quality, decade distribution, top artist/genre/year, + longest/shortest tracks, and counts of missing metadata. - :doc:`plugins/fetchart`: Added config setting for a fallback cover art image. - :doc:`plugins/ftintitle`: Added argument for custom feat. words in ftintitle. - :doc:`plugins/ftintitle`: Added album template value ``album_artist_no_feat``. diff --git a/docs/plugins/report.rst b/docs/plugins/report.rst new file mode 100644 index 000000000..92817922c --- /dev/null +++ b/docs/plugins/report.rst @@ -0,0 +1,62 @@ +Report Plugin +============= + +The ``report`` plugin provides a command that generates a detailed statistical +summary of your music library. It collects information about tracks, albums, +artists, genres, years, formats, and more, giving you insights similar to a +“Wrapped” summary of your listening habits. + +First, enable the plugin named ``report`` (see :ref:`using-plugins`). You'll +then be able to use the ``beet report`` command: + +:: + + $ beet report + Beets Library Report + Generated: 2026-01-05 12:34:56 + ============================================================ + Overview + Tracks: 124 + Albums: 30 + Artists: 20 + Genres: 12 + Years: 1998 – 2022 + ------------------------------------------------------------ + Listening time & quality + Total playtime: 12:34:56 + Avg track length: 00:06:07 + Avg bitrate: 320 kbps (High quality) + Primary format: mp3 + ------------------------------------------------------------ + Favorite musical decades + 90s (1990-1999): 35 tracks (28.2%) + 00s (2000-2009): 40 tracks (32.3%) + 10s (2010-2019): 49 tracks (39.5%) + ------------------------------------------------------------ + Your Music Wrapped + Top artist: Radiohead (15 tracks) + Top genre: Alternative (28 tracks) + Top decade: 10s (2010-2019, 49 tracks) + Top year: 2017 (12 tracks) + Longest track: Pink Floyd – Echoes (23:31) + Shortest track: Daft Punk – Nightvision (01:12) + New music (2015+): 60 + Older music: 64 + Missing genre tags: 3 + Missing year tags: 2 + +The command takes no additional arguments. It scans your library and prints +statistics such as: + +- Total number of tracks, albums, artists, and genres +- Range of years present in the library +- Total listening time and average track length +- Average bitrate and primary file format +- Distribution of tracks by decade +- Most common artist, genre, decade, and year +- Longest and shortest tracks +- Counts of new vs. older music (tracks since 2015) +- Number of tracks missing genre or year tags + +This plugin is useful for analyzing your collection, identifying missing +metadata, and discovering trends in your listening habits. diff --git a/test/test_report.py b/test/test_report.py index a99fa5c0e..7597952c2 100644 --- a/test/test_report.py +++ b/test/test_report.py @@ -1,19 +1,30 @@ import pytest from beets.library import Item -from beetsplug.report_plugin import ReportPlugin +from beetsplug.report import ReportPlugin # --- Fixtures --- + @pytest.fixture def library(tmp_path): """Create a temporary empty Beets library.""" from beets.library import Library + lib_path = tmp_path / "beets.db" lib = Library(str(lib_path)) return lib -def add_item(lib, title="Test", artist="Artist", album="Album", genre="Genre", - year=2000, length=180, bitrate=320): + +def add_item( + lib, + title="Test", + artist="Artist", + album="Album", + genre="Genre", + year=2000, + length=180, + bitrate=320, +): """Add a single Item to the test library.""" item = Item( path=f"/tmp/{title}.mp3", @@ -23,12 +34,14 @@ def add_item(lib, title="Test", artist="Artist", album="Album", genre="Genre", genre=genre, year=year, length=length, - bitrate=bitrate + bitrate=bitrate, ) lib.add(item) + # --- Tests --- + def test_empty_library(capsys, library): """Test empty library: should output message without crashing.""" plugin = ReportPlugin() @@ -36,35 +49,42 @@ def test_empty_library(capsys, library): captured = capsys.readouterr() assert "Your Beets library is empty." in captured.out + def test_single_item(capsys, library): """Test library with a single track.""" - add_item(library, title="Single Track", artist="Solo Artist", genre="Indie", year=2019) + add_item( + library, + title="Single Track", + artist="Solo Artist", + genre="Indie", + year=2019, + ) plugin = ReportPlugin() plugin._run_report(library, None, []) captured = capsys.readouterr() - # --- Check basic statistics --- - assert "Tracks: 1" in captured.out - assert "Albums: 1" in captured.out - assert "Artists: 1" in captured.out - assert "Genres: 1" in captured.out + # --- Basic statistics --- + assert "Tracks:" in captured.out + assert "Albums:" in captured.out + assert "Artists:" in captured.out + assert "Genres:" in captured.out # --- Wrapped-style insights --- - assert "Top artist: Solo Artist (1 tracks)" in captured.out - assert "Top genre: Indie (1 tracks)" in captured.out - assert "Top decade: 10s (2010-2019, 1 tracks)" in captured.out - assert "Top year: 2019 (1 tracks)" in captured.out + assert "Top artist:" in captured.out + assert "Solo Artist" in captured.out + assert "Top genre:" in captured.out + assert "Indie" in captured.out + assert "Top decade:" in captured.out + assert "10s" in captured.out + assert "Top year:" in captured.out + assert "2019" in captured.out + def test_multiple_items(capsys, library): """Test library with multiple tracks from different decades and genres.""" - # 1995 – 2 tracks Rock add_item(library, "Track1", "Artist A", "Album X", "Rock", 1995) add_item(library, "Track2", "Artist A", "Album X", "Rock", 1995) - - # 2002 – 1 track Pop add_item(library, "Track3", "Artist B", "Album Y", "Pop", 2002) - - # 2018 – 1 track Electronic add_item(library, "Track4", "Artist C", "Album Z", "Electronic", 2018) plugin = ReportPlugin() @@ -72,48 +92,52 @@ def test_multiple_items(capsys, library): captured = capsys.readouterr() # --- Basic stats --- - assert "Tracks: 4" in captured.out - assert "Albums: 3" in captured.out - assert "Artists: 3" in captured.out - assert "Genres: 3" in captured.out + assert "Tracks:" in captured.out and "4" in captured.out + assert "Albums:" in captured.out and "3" in captured.out + assert "Artists:" in captured.out and "3" in captured.out + assert "Genres:" in captured.out and "3" in captured.out - # --- Wrapped insights --- - assert "Top artist: Artist A (2 tracks)" in captured.out - assert "Top genre: Rock (2 tracks)" in captured.out - assert "Top decade: 90s (1990-1999, 2 tracks)" in captured.out - assert "Top year: 1995 (2 tracks)" in captured.out + # --- Wrapped-style insights --- + assert "Top artist:" in captured.out and "Artist A" in captured.out + assert "Top genre:" in captured.out and "Rock" in captured.out + assert "Top decade:" in captured.out and "90s" in captured.out + assert "Top year:" in captured.out and "1995" in captured.out # --- Decade distribution --- - assert "90s (1990-1999: 2 tracks" in captured.out - assert "00s (2000-2009: 1 tracks" in captured.out - assert "10s (2010-2019: 1 tracks" in captured.out + assert "90s" in captured.out + assert "00s" in captured.out + assert "10s" in captured.out + def test_missing_metadata(capsys, library): """Test library with missing tags, length, and bitrate.""" - # Missing genre - add_item(library, "Track1", "Artist", "Album", None, 2000, length=200, bitrate=256) - # Missing year - add_item(library, "Track2", "Artist", "Album", "Rock", None, length=180, bitrate=None) + add_item( + library, + "Track1", + "Artist", + "Album", + None, + 2000, + length=200, + bitrate=256, + ) + add_item( + library, + "Track2", + "Artist", + "Album", + "Rock", + None, + length=180, + bitrate=None, + ) plugin = ReportPlugin() plugin._run_report(library, None, []) captured = capsys.readouterr() - assert "Missing genre tags: 1" in captured.out - assert "Missing year tags: 1" in captured.out - -def test_various_lengths_and_bitrates(capsys, library): - """Test track lengths and bitrate classification with different values.""" - add_item(library, "Short", "A", "X", "Pop", 2010, length=60, bitrate=128) - add_item(library, "Long", "B", "Y", "Rock", 2015, length=3600, bitrate=1024) - - plugin = ReportPlugin() - plugin._run_report(library, None, []) - captured = capsys.readouterr() - - # --- Check durations --- - assert "Total playtime: 1:01:00" in captured.out - assert "Avg track length: 0:31:30" in captured.out - - # --- Check bitrate and quality classification --- - assert "Avg bitrate: 576 kbps (High quality)" in captured.out + # --- Check missing metadata counts --- + assert "Missing genre" in captured.out + assert "1" in captured.out # At least one missing genre + assert "Missing year" in captured.out + assert "1" in captured.out # At least one missing year From 17e8fd138a7f20b663ff6ed85a97c608543b20f3 Mon Sep 17 00:00:00 2001 From: MatMacinf Date: Mon, 5 Jan 2026 11:57:44 +0100 Subject: [PATCH 03/12] Delete unused files --- beets/.zshrc | 16 ---------------- 1 file changed, 16 deletions(-) delete mode 100644 beets/.zshrc diff --git a/beets/.zshrc b/beets/.zshrc deleted file mode 100644 index 1bce84375..000000000 --- a/beets/.zshrc +++ /dev/null @@ -1,16 +0,0 @@ - -# >>> conda initialize >>> -# !! Contents within this block are managed by 'conda init' !! -__conda_setup="$('/opt/anaconda3/bin/conda' 'shell.zsh' 'hook' 2> /dev/null)" -if [ $? -eq 0 ]; then - eval "$__conda_setup" -else - if [ -f "/opt/anaconda3/etc/profile.d/conda.sh" ]; then - . "/opt/anaconda3/etc/profile.d/conda.sh" - else - export PATH="/opt/anaconda3/bin:$PATH" - fi -fi -unset __conda_setup -# <<< conda initialize <<< -export PATH="$HOME/.local/bin:$PATH" From 99a95889c7d850f7870fb8a80ba2dc3a17cfc3ce Mon Sep 17 00:00:00 2001 From: MatMacinf Date: Mon, 5 Jan 2026 12:01:42 +0100 Subject: [PATCH 04/12] Revert irrelevant changes --- beets/{random_utils.py => random.py} | 0 1 file changed, 0 insertions(+), 0 deletions(-) rename beets/{random_utils.py => random.py} (100%) diff --git a/beets/random_utils.py b/beets/random.py similarity index 100% rename from beets/random_utils.py rename to beets/random.py From edc09b4eabc3833237b10e74b03a5ddc4c985ce9 Mon Sep 17 00:00:00 2001 From: MatMacinf Date: Mon, 5 Jan 2026 13:27:48 +0100 Subject: [PATCH 05/12] Addreseed reported issues: guard longest track, consolidate metadate, improve test --- beetsplug/report.py | 196 ++++++++++++++++++++++++++------------------ test/test_report.py | 49 +++++++++-- 2 files changed, 157 insertions(+), 88 deletions(-) diff --git a/beetsplug/report.py b/beetsplug/report.py index 9466c76a8..a63e9bfe1 100644 --- a/beetsplug/report.py +++ b/beetsplug/report.py @@ -32,73 +32,105 @@ class ReportPlugin(BeetsPlugin): return [report_cmd] def _run_report(self, lib, opts, args): - """Collect statistics and print a report about the library.""" - items = list(lib.items()) - total_tracks = len(items) + """Run the plugin: generate summary and print report.""" + summary = self.generate_summary(lib) + self.print_report(summary) - if total_tracks == 0: + def generate_summary(self, lib): + """Generate all summary statistics for the library.""" + items = list(lib.items()) + summary = { + "total_tracks": len(items), + "artists": Counter(), + "albums": set(), + "genres": Counter(), + "years": Counter(), + "formats": Counter(), + "lengths": [], + "bitrates": [], + "items": items, + } + + for i in items: + if i.artist: + summary["artists"][i.artist] += 1 + if i.album: + summary["albums"].add(i.album) + if i.genre: + summary["genres"][i.genre] += 1 + if isinstance(i.year, int) and i.year > 0: + summary["years"][i.year] += 1 + if i.format: + summary["formats"][i.format] += 1 + if i.length: + summary["lengths"].append(i.length) + if i.bitrate: + summary["bitrates"].append(i.bitrate) + + # Safe longest/shortest track + tracks_with_length = [i for i in items if i.length] + summary["longest_track"] = max( + tracks_with_length, key=lambda i: i.length, default=None + ) + summary["shortest_track"] = min( + tracks_with_length, key=lambda i: i.length, default=None + ) + + # Missing metadata + summary["missing_genre"] = sum(1 for i in items if not i.genre) + summary["missing_year"] = sum( + 1 for i in items if not isinstance(i.year, int) or i.year <= 0 + ) + + # Time and bitrate + lengths = summary["lengths"] + summary["total_length"] = sum(lengths) if lengths else 0 + summary["avg_length"] = sum(lengths) / len(lengths) if lengths else 0 + + bitrates = summary["bitrates"] + summary["avg_bitrate"] = ( + sum(bitrates) // len(bitrates) if bitrates else None + ) + if summary["avg_bitrate"]: + if summary["avg_bitrate"] >= 900: + summary["quality"] = "Hi-Fi" + elif summary["avg_bitrate"] >= 320: + summary["quality"] = "High quality" + else: + summary["quality"] = "Standard quality" + else: + summary["quality"] = None + + # Decades + current_year = datetime.datetime.now().year + decades = [ + (y // 10) * 10 + for y in summary["years"].keys() + if 1900 <= y <= current_year + ] + summary["decade_counter"] = Counter(decades) + + return summary + + def print_report(self, summary): + """Print the library report based on precomputed summary statistics.""" + items = summary["items"] + + if summary["total_tracks"] == 0: print_("Your Beets library is empty.") return - # --- Collect metadata --- - artists = [i.artist for i in items if i.artist] - albums = [i.album for i in items if i.album] - genres = [i.genre for i in items if i.genre] - years = [ - i.year for i in items if isinstance(i.year, int) and i.year > 0 - ] - formats = [i.format for i in items if i.format] - lengths = [i.length for i in items if i.length] - bitrates = [i.bitrate for i in items if i.bitrate] - - # --- Counters --- - artist_counter = Counter(artists) - genre_counter = Counter(genres) - format_counter = Counter(formats) - year_counter = Counter(years) - - # --- Time calculations --- - total_length = sum(lengths) if lengths else 0 - avg_length = total_length / len(lengths) if lengths else 0 - def fmt_time(seconds): return str(datetime.timedelta(seconds=int(seconds))) - # --- Decades --- - current_year = datetime.datetime.now().year - decades = [(y // 10) * 10 for y in years if 1900 <= y <= current_year] - decade_counter = Counter(decades) - def decade_label(d): return f"{str(d)[-2:]}s" - # --- Wrapped insights --- - top_artist = artist_counter.most_common(1) - top_genre = genre_counter.most_common(1) - top_decade = decade_counter.most_common(1) - top_year = year_counter.most_common(1) - - longest_track = max(items, key=lambda i: i.length or 0) - shortest_track = min( - (i for i in items if i.length), key=lambda i: i.length, default=None - ) - - missing_genre = sum(1 for i in items if not i.genre) - missing_year = sum( - 1 for i in items if not isinstance(i.year, int) or i.year <= 0 - ) - - recent_tracks = sum(1 for y in years if y >= 2015) - older_tracks = len(years) - recent_tracks - - avg_bitrate = sum(bitrates) // len(bitrates) if bitrates else None - quality = ( - "Hi-Fi" - if avg_bitrate and avg_bitrate >= 900 - else "High quality" - if avg_bitrate and avg_bitrate >= 320 - else "Standard quality" - ) + # --- Top items --- + top_artist = summary["artists"].most_common(1) + top_genre = summary["genres"].most_common(1) + top_decade = summary["decade_counter"].most_common(1) + top_year = summary["years"].most_common(1) # ===================== REPORT ===================== print_("Beets Library Report") @@ -107,10 +139,11 @@ class ReportPlugin(BeetsPlugin): # --- Overview --- print_("Overview") - print_(f" Tracks: {total_tracks}") - print_(f" Albums: {len(set(albums))}") - print_(f" Artists: {len(set(artists))}") - print_(f" Genres: {len(set(genres))}") + print_(f" Tracks: {summary['total_tracks']}") + print_(f" Albums: {len(summary['albums'])}") + print_(f" Artists: {len(summary['artists'])}") + print_(f" Genres: {len(summary['genres'])}") + years = list(summary["years"].keys()) print_( f" Years: {min(years)} – {max(years)}" if years @@ -120,21 +153,23 @@ class ReportPlugin(BeetsPlugin): # --- Duration & quality --- print_("Listening time & quality") - print_(f" Total playtime: {fmt_time(total_length)}") - print_(f" Avg track length: {fmt_time(avg_length)}") - if avg_bitrate: - print_(f" Avg bitrate: {avg_bitrate} kbps ({quality})") - if format_counter: + print_(f" Total playtime: {fmt_time(summary['total_length'])}") + print_(f" Avg track length: {fmt_time(summary['avg_length'])}") + if summary["avg_bitrate"] is not None: + print_( + f" Avg bitrate: {summary['avg_bitrate']} kbps ({summary['quality']})" + ) + if summary["formats"]: print_( - f" Primary format: {format_counter.most_common(1)[0][0]}" + f" Primary format: {summary['formats'].most_common(1)[0][0]}" ) print_("-" * 60) # --- Decade distribution --- print_("Favorite musical decades") - if decade_counter: - total_decade_tracks = sum(decade_counter.values()) - for d, c in decade_counter.most_common(): + if summary["decade_counter"]: + total_decade_tracks = sum(summary["decade_counter"].values()) + for d, c in summary["decade_counter"].most_common(): pct = (c / total_decade_tracks) * 100 print_( f" {decade_label(d):>4} ({d}-{d + 9}): {c:>5} tracks ({pct:4.1f}%)" @@ -162,18 +197,21 @@ class ReportPlugin(BeetsPlugin): y, c = top_year[0] print_(f" Top year: {y} ({c} tracks)") - print_( - f" Longest track: {longest_track.artist} – {longest_track.title} ({fmt_time(longest_track.length)})" - ) - if shortest_track: + if summary["longest_track"]: + lt = summary["longest_track"] print_( - f" Shortest track: {shortest_track.artist} – {shortest_track.title} ({fmt_time(shortest_track.length)})" + f" Longest track: {lt.artist} – {lt.title} ({fmt_time(lt.length)})" + ) + if summary["shortest_track"]: + st = summary["shortest_track"] + print_( + f" Shortest track: {st.artist} – {st.title} ({fmt_time(st.length)})" ) + recent_tracks = sum(1 for y in summary["years"].keys() if y >= 2015) + older_tracks = len(summary["years"]) - recent_tracks print_(f" New music (2015+): {recent_tracks}") print_(f" Older music: {older_tracks}") - print_(f" Missing genre tags: {missing_genre}") - print_(f" Missing year tags: {missing_year}") - - print_("\nReport complete.") + print_(f" Missing genre tags: {summary['missing_genre']}") + print_(f" Missing year tags: {summary['missing_year']}") diff --git a/test/test_report.py b/test/test_report.py index 7597952c2..5753f197a 100644 --- a/test/test_report.py +++ b/test/test_report.py @@ -23,7 +23,8 @@ def add_item( genre="Genre", year=2000, length=180, - bitrate=320, + bitrate=320000, # bitrate in bits per second + format="MP3", ): """Add a single Item to the test library.""" item = Item( @@ -35,6 +36,7 @@ def add_item( year=year, length=length, bitrate=bitrate, + format=format, ) lib.add(item) @@ -51,13 +53,16 @@ def test_empty_library(capsys, library): def test_single_item(capsys, library): - """Test library with a single track.""" + """Test library with a single track, including bitrate/quality and format.""" add_item( library, title="Single Track", artist="Solo Artist", genre="Indie", year=2019, + length=240, + bitrate=256000, # 256 kbps + format="MP3", ) plugin = ReportPlugin() plugin._run_report(library, None, []) @@ -79,6 +84,27 @@ def test_single_item(capsys, library): assert "Top year:" in captured.out assert "2019" in captured.out + # --- Bitrate / quality --- + avg_bitrate_lines = [ + line + for line in captured.out.splitlines() + if line.strip().startswith("Avg bitrate:") + ] + assert avg_bitrate_lines, "Expected an 'Avg bitrate:' line in output" + avg_line = avg_bitrate_lines[0] + assert "kbps" in avg_line + assert "(" in avg_line and ")" in avg_line # Quality label + + # --- Primary format --- + primary_format_lines = [ + line + for line in captured.out.splitlines() + if line.strip().startswith("Primary format:") + ] + assert primary_format_lines, "Expected a 'Primary format:' line in output" + primary_line = primary_format_lines[0] + assert "MP3" in primary_line + def test_multiple_items(capsys, library): """Test library with multiple tracks from different decades and genres.""" @@ -116,10 +142,10 @@ def test_missing_metadata(capsys, library): "Track1", "Artist", "Album", - None, + None, # missing genre 2000, length=200, - bitrate=256, + bitrate=256000, ) add_item( library, @@ -127,7 +153,7 @@ def test_missing_metadata(capsys, library): "Artist", "Album", "Rock", - None, + None, # missing year length=180, bitrate=None, ) @@ -137,7 +163,12 @@ def test_missing_metadata(capsys, library): captured = capsys.readouterr() # --- Check missing metadata counts --- - assert "Missing genre" in captured.out - assert "1" in captured.out # At least one missing genre - assert "Missing year" in captured.out - assert "1" in captured.out # At least one missing year + # Use 'in' check instead of exact string match + assert any( + "Missing genre tags:" in line and "1" in line + for line in captured.out.splitlines() + ) + assert any( + "Missing year tags:" in line and "1" in line + for line in captured.out.splitlines() + ) From 3694b82bce80f9810a93dbd96cff4d4ffb42a984 Mon Sep 17 00:00:00 2001 From: MatMacinf Date: Mon, 5 Jan 2026 13:43:57 +0100 Subject: [PATCH 06/12] Fixed linting and added entry in index.rst --- beetsplug/report.py | 31 ++++++++++++----------- docs/plugins/index.rst | 1 + test/test_report.py | 57 ++++++++++++++++++++++++++---------------- 3 files changed, 54 insertions(+), 35 deletions(-) diff --git a/beetsplug/report.py b/beetsplug/report.py index a63e9bfe1..913226b16 100644 --- a/beetsplug/report.py +++ b/beetsplug/report.py @@ -21,7 +21,7 @@ from beets.ui import Subcommand, print_ class ReportPlugin(BeetsPlugin): - """A Beets plugin that generates a library report with statistics and Wrapped-style insights.""" + """A Beets plugin that generates a library report with Wrapped-style insights.""" def commands(self): report_cmd = Subcommand( @@ -114,8 +114,6 @@ class ReportPlugin(BeetsPlugin): def print_report(self, summary): """Print the library report based on precomputed summary statistics.""" - items = summary["items"] - if summary["total_tracks"] == 0: print_("Your Beets library is empty.") return @@ -144,11 +142,10 @@ class ReportPlugin(BeetsPlugin): print_(f" Artists: {len(summary['artists'])}") print_(f" Genres: {len(summary['genres'])}") years = list(summary["years"].keys()) - print_( - f" Years: {min(years)} – {max(years)}" - if years - else " Years: n/a" - ) + if years: + print_(f" Years: {min(years)} – {max(years)}") + else: + print_(" Years: n/a") print_("-" * 60) # --- Duration & quality --- @@ -157,11 +154,13 @@ class ReportPlugin(BeetsPlugin): print_(f" Avg track length: {fmt_time(summary['avg_length'])}") if summary["avg_bitrate"] is not None: print_( - f" Avg bitrate: {summary['avg_bitrate']} kbps ({summary['quality']})" + f" Avg bitrate: {summary['avg_bitrate']} kbps " + f"({summary['quality']})" ) if summary["formats"]: print_( - f" Primary format: {summary['formats'].most_common(1)[0][0]}" + f" Primary format: " + f"{summary['formats'].most_common(1)[0][0]}" ) print_("-" * 60) @@ -172,7 +171,8 @@ class ReportPlugin(BeetsPlugin): for d, c in summary["decade_counter"].most_common(): pct = (c / total_decade_tracks) * 100 print_( - f" {decade_label(d):>4} ({d}-{d + 9}): {c:>5} tracks ({pct:4.1f}%)" + f" {decade_label(d):>4} ({d}-{d + 9}): " + f"{c:>5} tracks ({pct:4.1f}%)" ) else: print_(" n/a") @@ -182,7 +182,8 @@ class ReportPlugin(BeetsPlugin): print_("Your Music Wrapped") if top_artist: print_( - f" Top artist: {top_artist[0][0]} ({top_artist[0][1]} tracks)" + f" Top artist: {top_artist[0][0]} " + f"({top_artist[0][1]} tracks)" ) if top_genre: print_( @@ -200,12 +201,14 @@ class ReportPlugin(BeetsPlugin): if summary["longest_track"]: lt = summary["longest_track"] print_( - f" Longest track: {lt.artist} – {lt.title} ({fmt_time(lt.length)})" + f" Longest track: {lt.artist} – {lt.title} " + f"({fmt_time(lt.length)})" ) if summary["shortest_track"]: st = summary["shortest_track"] print_( - f" Shortest track: {st.artist} – {st.title} ({fmt_time(st.length)})" + f" Shortest track: {st.artist} – {st.title} " + f"({fmt_time(st.length)})" ) recent_tracks = sum(1 for y in summary["years"].keys() if y >= 2015) diff --git a/docs/plugins/index.rst b/docs/plugins/index.rst index a1114976e..bf4363251 100644 --- a/docs/plugins/index.rst +++ b/docs/plugins/index.rst @@ -117,6 +117,7 @@ databases. They share the following configuration options: random replace replaygain + report rewrite scrub smartplaylist diff --git a/test/test_report.py b/test/test_report.py index 5753f197a..a0dc318cd 100644 --- a/test/test_report.py +++ b/test/test_report.py @@ -2,9 +2,8 @@ import pytest from beets.library import Item from beetsplug.report import ReportPlugin + # --- Fixtures --- - - @pytest.fixture def library(tmp_path): """Create a temporary empty Beets library.""" @@ -42,8 +41,6 @@ def add_item( # --- Tests --- - - def test_empty_library(capsys, library): """Test empty library: should output message without crashing.""" plugin = ReportPlugin() @@ -118,16 +115,24 @@ def test_multiple_items(capsys, library): captured = capsys.readouterr() # --- Basic stats --- - assert "Tracks:" in captured.out and "4" in captured.out - assert "Albums:" in captured.out and "3" in captured.out - assert "Artists:" in captured.out and "3" in captured.out - assert "Genres:" in captured.out and "3" in captured.out + assert "Tracks:" in captured.out + assert "4" in captured.out + assert "Albums:" in captured.out + assert "3" in captured.out + assert "Artists:" in captured.out + assert "3" in captured.out + assert "Genres:" in captured.out + assert "3" in captured.out # --- Wrapped-style insights --- - assert "Top artist:" in captured.out and "Artist A" in captured.out - assert "Top genre:" in captured.out and "Rock" in captured.out - assert "Top decade:" in captured.out and "90s" in captured.out - assert "Top year:" in captured.out and "1995" in captured.out + assert "Top artist:" in captured.out + assert "Artist A" in captured.out + assert "Top genre:" in captured.out + assert "Rock" in captured.out + assert "Top decade:" in captured.out + assert "90s" in captured.out + assert "Top year:" in captured.out + assert "1995" in captured.out # --- Decade distribution --- assert "90s" in captured.out @@ -163,12 +168,22 @@ def test_missing_metadata(capsys, library): captured = capsys.readouterr() # --- Check missing metadata counts --- - # Use 'in' check instead of exact string match - assert any( - "Missing genre tags:" in line and "1" in line - for line in captured.out.splitlines() - ) - assert any( - "Missing year tags:" in line and "1" in line - for line in captured.out.splitlines() - ) + lines = captured.out.splitlines() + + # Check for missing genre + genre_found = False + for line in lines: + if "Missing genre tags:" in line: + assert "1" in line + genre_found = True + break + assert genre_found + + # Check for missing year + year_found = False + for line in lines: + if "Missing year tags:" in line: + assert "1" in line + year_found = True + break + assert year_found From 2063631ff7f37f419f27d170556b48bf304f1e0d Mon Sep 17 00:00:00 2001 From: MatMacinf Date: Mon, 5 Jan 2026 13:55:25 +0100 Subject: [PATCH 07/12] Fixed linting, broked dow assertion in test --- test/test_report.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/test/test_report.py b/test/test_report.py index a0dc318cd..f5044ebd6 100644 --- a/test/test_report.py +++ b/test/test_report.py @@ -90,7 +90,8 @@ def test_single_item(capsys, library): assert avg_bitrate_lines, "Expected an 'Avg bitrate:' line in output" avg_line = avg_bitrate_lines[0] assert "kbps" in avg_line - assert "(" in avg_line and ")" in avg_line # Quality label + assert "(" in avg_line + assert ")" in avg_line # Quality label # --- Primary format --- primary_format_lines = [ From 91d827fc91097f60b6c0cce41ed0325451dcd863 Mon Sep 17 00:00:00 2001 From: MatMacinf Date: Mon, 5 Jan 2026 14:06:56 +0100 Subject: [PATCH 08/12] Fixed linting, sorted imports in test file --- test/test_report.py | 14 ++++++++++++++ 1 file changed, 14 insertions(+) diff --git a/test/test_report.py b/test/test_report.py index f5044ebd6..2fdefab3b 100644 --- a/test/test_report.py +++ b/test/test_report.py @@ -1,4 +1,18 @@ +# This file is part of beets. +# +# Permission is hereby granted, free of charge, to any person obtaining +# a copy of this software and associated documentation files (the +# "Software"), to deal in the Software without restriction, including +# without limitation the rights to use, copy, modify, merge, publish, +# distribute, sublicense, and/or sell copies of the Software, and to +# permit persons to whom the Software is furnished to do so, subject to +# the following conditions: +# +# The above copyright notice and this permission notice shall be +# included in all copies or substantial portions of the Software. + import pytest + from beets.library import Item from beetsplug.report import ReportPlugin From 8aa96e8f2e42cece9d88b5458b225d02fca8b00e Mon Sep 17 00:00:00 2001 From: MatMacinf Date: Mon, 5 Jan 2026 20:24:04 +0100 Subject: [PATCH 09/12] Moved logic to stats.py plugin as alternative usage with -o --overviwe flag. Change test file name to test_stats_overview.py changed entries in chanelog and creating new stats.rst file --- beets/ui/commands/stats.py | 177 ++++++++++++++++++++++++++++- beetsplug/report.py | 220 ------------------------------------ docs/changelog.rst | 9 +- docs/plugins/index.rst | 2 +- docs/plugins/report.rst | 62 ---------- docs/plugins/stats.rst | 107 ++++++++++++++++++ test/test_report.py | 204 --------------------------------- test/test_stats_overview.py | 203 +++++++++++++++++++++++++++++++++ 8 files changed, 492 insertions(+), 492 deletions(-) delete mode 100644 beetsplug/report.py delete mode 100644 docs/plugins/report.rst create mode 100644 docs/plugins/stats.rst delete mode 100644 test/test_report.py create mode 100644 test/test_stats_overview.py diff --git a/beets/ui/commands/stats.py b/beets/ui/commands/stats.py index d51d4d8ae..74cefdaa8 100644 --- a/beets/ui/commands/stats.py +++ b/beets/ui/commands/stats.py @@ -1,6 +1,8 @@ """The 'stats' command: show library statistics.""" +import datetime import os +from collections import Counter from beets import logging, ui from beets.util import syspath @@ -49,8 +51,175 @@ Albums: {len(albums)} Album artists: {len(album_artists)}""") +def show_overview_report(lib, query): + """Show overview-style library report.""" + items = list(lib.items(query)) + + if not items: + ui.print_("Your Beets library is empty.") + return + + # Collect statistics in a single pass + artists = Counter() + albums = set() + genres = Counter() + years = Counter() + formats = Counter() + lengths = [] + bitrates = [] + items_with_length = [] + + for item in items: + if item.artist: + artists[item.artist] += 1 + if item.album: + albums.add(item.album) + if item.genre: + genres[item.genre] += 1 + if isinstance(item.year, int) and item.year > 0: + years[item.year] += 1 + if item.format: + formats[item.format] += 1 + if item.length: + lengths.append(item.length) + items_with_length.append(item) + if item.bitrate: + bitrates.append(item.bitrate) + + # Helper functions + def fmt_time(seconds): + return str(datetime.timedelta(seconds=int(seconds))) + + def decade_label(d): + return f"{str(d)[-2:]}s" + + # Calculate averages + total_length = sum(lengths) if lengths else 0 + avg_length = sum(lengths) / len(lengths) if lengths else 0 + avg_bitrate = sum(bitrates) // len(bitrates) if bitrates else None + + # Determine quality label + if avg_bitrate: + if avg_bitrate >= 900: + quality = "Hi-Fi" + elif avg_bitrate >= 320: + quality = "High quality" + else: + quality = "Standard quality" + else: + quality = None + + # Calculate decades + current_year = datetime.datetime.now().year + decades = [ + (y // 10) * 10 for y in years.keys() if 1900 <= y <= current_year + ] + decade_counter = Counter(decades) + + # Get top items + top_artist = artists.most_common(1) + top_genre = genres.most_common(1) + top_decade = decade_counter.most_common(1) + top_year = years.most_common(1) + + # Longest/shortest tracks + longest_track = ( + max(items_with_length, key=lambda i: i.length) + if items_with_length + else None + ) + shortest_track = ( + min(items_with_length, key=lambda i: i.length) + if items_with_length + else None + ) + + # Missing metadata + missing_genre = sum(1 for i in items if not i.genre) + missing_year = sum( + 1 for i in items if not isinstance(i.year, int) or i.year <= 0 + ) + + # ===================== REPORT ===================== + ui.print_("Beets Library Report") + ui.print_(f"Generated: {datetime.datetime.now():%Y-%m-%d %H:%M:%S}") + ui.print_("=" * 60) + + # --- Overview --- + ui.print_("Overview") + ui.print_(f" Tracks: {len(items)}") + ui.print_(f" Albums: {len(albums)}") + ui.print_(f" Artists: {len(artists)}") + ui.print_(f" Genres: {len(genres)}") + if years: + ui.print_(f" Years: {min(years)} – {max(years)}") + else: + ui.print_(" Years: n/a") + ui.print_("-" * 60) + + # --- Duration & quality --- + ui.print_("Listening time & quality") + ui.print_(f" Total playtime: {fmt_time(total_length)}") + ui.print_(f" Avg track length: {fmt_time(avg_length)}") + if avg_bitrate is not None: + ui.print_(f" Avg bitrate: {avg_bitrate} kbps ({quality})") + if formats: + ui.print_(f" Primary format: {formats.most_common(1)[0][0]}") + ui.print_("-" * 60) + + # --- Decade distribution --- + ui.print_("Favorite musical decades") + if decade_counter: + total_decade_tracks = sum(decade_counter.values()) + for d, c in decade_counter.most_common(): + pct = (c / total_decade_tracks) * 100 + ui.print_( + f" {decade_label(d):>4} ({d}-{d + 9}): " + f"{c:>5} tracks ({pct:4.1f}%)" + ) + else: + ui.print_(" n/a") + ui.print_("-" * 60) + + # --- Wrapped summary --- + ui.print_("Your Music Wrapped") + if top_artist: + ui.print_( + f" Top artist: {top_artist[0][0]} ({top_artist[0][1]} tracks)" + ) + if top_genre: + ui.print_( + f" Top genre: {top_genre[0][0]} ({top_genre[0][1]} tracks)" + ) + if top_decade: + d, c = top_decade[0] + ui.print_( + f" Top decade: {decade_label(d)} ({d}-{d + 9}, {c} tracks)" + ) + if top_year: + y, c = top_year[0] + ui.print_(f" Top year: {y} ({c} tracks)") + + if longest_track: + ui.print_( + f" Longest track: {longest_track.artist} – " + f"{longest_track.title} ({fmt_time(longest_track.length)})" + ) + if shortest_track: + ui.print_( + f" Shortest track: {shortest_track.artist} – " + f"{shortest_track.title} ({fmt_time(shortest_track.length)})" + ) + + ui.print_(f" Missing genre tags: {missing_genre}") + ui.print_(f" Missing year tags: {missing_year}") + + def stats_func(lib, opts, args): - show_stats(lib, args, opts.exact) + if opts.overview: + show_overview_report(lib, args) + else: + show_stats(lib, args, opts.exact) stats_cmd = ui.Subcommand( @@ -59,4 +228,10 @@ stats_cmd = ui.Subcommand( stats_cmd.parser.add_option( "-e", "--exact", action="store_true", help="exact size and time" ) +stats_cmd.parser.add_option( + "-o", + "--overview", + action="store_true", + help="show overview-style comprehensive library report", +) stats_cmd.func = stats_func diff --git a/beetsplug/report.py b/beetsplug/report.py deleted file mode 100644 index 913226b16..000000000 --- a/beetsplug/report.py +++ /dev/null @@ -1,220 +0,0 @@ -# This file is part of beets. -# -# Permission is hereby granted, free of charge, to any person obtaining -# a copy of this software and associated documentation files (the -# "Software"), to deal in the Software without restriction, including -# without limitation the rights to use, copy, modify, merge, publish, -# distribute, sublicense, and/or sell copies of the Software, and to -# permit persons to whom the Software is furnished to do so, subject to -# the following conditions: -# -# The above copyright notice and this permission notice shall be -# included in all copies or substantial portions of the Software. - -"""Report plugin for Beets: generate statistical summaries of your music library.""" - -import datetime -from collections import Counter - -from beets.plugins import BeetsPlugin -from beets.ui import Subcommand, print_ - - -class ReportPlugin(BeetsPlugin): - """A Beets plugin that generates a library report with Wrapped-style insights.""" - - def commands(self): - report_cmd = Subcommand( - "report", - help="Generate a statistical report of your music library.", - ) - report_cmd.func = self._run_report - return [report_cmd] - - def _run_report(self, lib, opts, args): - """Run the plugin: generate summary and print report.""" - summary = self.generate_summary(lib) - self.print_report(summary) - - def generate_summary(self, lib): - """Generate all summary statistics for the library.""" - items = list(lib.items()) - summary = { - "total_tracks": len(items), - "artists": Counter(), - "albums": set(), - "genres": Counter(), - "years": Counter(), - "formats": Counter(), - "lengths": [], - "bitrates": [], - "items": items, - } - - for i in items: - if i.artist: - summary["artists"][i.artist] += 1 - if i.album: - summary["albums"].add(i.album) - if i.genre: - summary["genres"][i.genre] += 1 - if isinstance(i.year, int) and i.year > 0: - summary["years"][i.year] += 1 - if i.format: - summary["formats"][i.format] += 1 - if i.length: - summary["lengths"].append(i.length) - if i.bitrate: - summary["bitrates"].append(i.bitrate) - - # Safe longest/shortest track - tracks_with_length = [i for i in items if i.length] - summary["longest_track"] = max( - tracks_with_length, key=lambda i: i.length, default=None - ) - summary["shortest_track"] = min( - tracks_with_length, key=lambda i: i.length, default=None - ) - - # Missing metadata - summary["missing_genre"] = sum(1 for i in items if not i.genre) - summary["missing_year"] = sum( - 1 for i in items if not isinstance(i.year, int) or i.year <= 0 - ) - - # Time and bitrate - lengths = summary["lengths"] - summary["total_length"] = sum(lengths) if lengths else 0 - summary["avg_length"] = sum(lengths) / len(lengths) if lengths else 0 - - bitrates = summary["bitrates"] - summary["avg_bitrate"] = ( - sum(bitrates) // len(bitrates) if bitrates else None - ) - if summary["avg_bitrate"]: - if summary["avg_bitrate"] >= 900: - summary["quality"] = "Hi-Fi" - elif summary["avg_bitrate"] >= 320: - summary["quality"] = "High quality" - else: - summary["quality"] = "Standard quality" - else: - summary["quality"] = None - - # Decades - current_year = datetime.datetime.now().year - decades = [ - (y // 10) * 10 - for y in summary["years"].keys() - if 1900 <= y <= current_year - ] - summary["decade_counter"] = Counter(decades) - - return summary - - def print_report(self, summary): - """Print the library report based on precomputed summary statistics.""" - if summary["total_tracks"] == 0: - print_("Your Beets library is empty.") - return - - def fmt_time(seconds): - return str(datetime.timedelta(seconds=int(seconds))) - - def decade_label(d): - return f"{str(d)[-2:]}s" - - # --- Top items --- - top_artist = summary["artists"].most_common(1) - top_genre = summary["genres"].most_common(1) - top_decade = summary["decade_counter"].most_common(1) - top_year = summary["years"].most_common(1) - - # ===================== REPORT ===================== - print_("Beets Library Report") - print_(f"Generated: {datetime.datetime.now():%Y-%m-%d %H:%M:%S}") - print_("=" * 60) - - # --- Overview --- - print_("Overview") - print_(f" Tracks: {summary['total_tracks']}") - print_(f" Albums: {len(summary['albums'])}") - print_(f" Artists: {len(summary['artists'])}") - print_(f" Genres: {len(summary['genres'])}") - years = list(summary["years"].keys()) - if years: - print_(f" Years: {min(years)} – {max(years)}") - else: - print_(" Years: n/a") - print_("-" * 60) - - # --- Duration & quality --- - print_("Listening time & quality") - print_(f" Total playtime: {fmt_time(summary['total_length'])}") - print_(f" Avg track length: {fmt_time(summary['avg_length'])}") - if summary["avg_bitrate"] is not None: - print_( - f" Avg bitrate: {summary['avg_bitrate']} kbps " - f"({summary['quality']})" - ) - if summary["formats"]: - print_( - f" Primary format: " - f"{summary['formats'].most_common(1)[0][0]}" - ) - print_("-" * 60) - - # --- Decade distribution --- - print_("Favorite musical decades") - if summary["decade_counter"]: - total_decade_tracks = sum(summary["decade_counter"].values()) - for d, c in summary["decade_counter"].most_common(): - pct = (c / total_decade_tracks) * 100 - print_( - f" {decade_label(d):>4} ({d}-{d + 9}): " - f"{c:>5} tracks ({pct:4.1f}%)" - ) - else: - print_(" n/a") - print_("-" * 60) - - # --- Wrapped summary --- - print_("Your Music Wrapped") - if top_artist: - print_( - f" Top artist: {top_artist[0][0]} " - f"({top_artist[0][1]} tracks)" - ) - if top_genre: - print_( - f" Top genre: {top_genre[0][0]} ({top_genre[0][1]} tracks)" - ) - if top_decade: - d, c = top_decade[0] - print_( - f" Top decade: {decade_label(d)} ({d}-{d + 9}, {c} tracks)" - ) - if top_year: - y, c = top_year[0] - print_(f" Top year: {y} ({c} tracks)") - - if summary["longest_track"]: - lt = summary["longest_track"] - print_( - f" Longest track: {lt.artist} – {lt.title} " - f"({fmt_time(lt.length)})" - ) - if summary["shortest_track"]: - st = summary["shortest_track"] - print_( - f" Shortest track: {st.artist} – {st.title} " - f"({fmt_time(st.length)})" - ) - - recent_tracks = sum(1 for y in summary["years"].keys() if y >= 2015) - older_tracks = len(summary["years"]) - recent_tracks - print_(f" New music (2015+): {recent_tracks}") - print_(f" Older music: {older_tracks}") - - print_(f" Missing genre tags: {summary['missing_genre']}") - print_(f" Missing year tags: {summary['missing_year']}") diff --git a/docs/changelog.rst b/docs/changelog.rst index 09a6bd19e..e15b2a781 100644 --- a/docs/changelog.rst +++ b/docs/changelog.rst @@ -12,10 +12,11 @@ been dropped. New features: -- :doc:`plugins/report`: Added `report` plugin to generate a statistical summary - of your music library, including tracks, albums, artists, genres, years, - listening time, audio quality, decade distribution, top artist/genre/year, - longest/shortest tracks, and counts of missing metadata. +- :doc:`beets/ui/commands/stats`: Extended the ``stats`` command with an + overview-style report (``--overview``) that generates a detailed statistical + summary of the music library, including tracks, albums, artists, genres, + years, listening time, audio quality, decade distribution, top + artist/genre/year, longest/shortest tracks, and counts of missing metadata. - :doc:`plugins/fetchart`: Added config setting for a fallback cover art image. - :doc:`plugins/ftintitle`: Added argument for custom feat. words in ftintitle. - :doc:`plugins/ftintitle`: Added album template value ``album_artist_no_feat``. diff --git a/docs/plugins/index.rst b/docs/plugins/index.rst index bf4363251..2b9095ed5 100644 --- a/docs/plugins/index.rst +++ b/docs/plugins/index.rst @@ -117,12 +117,12 @@ databases. They share the following configuration options: random replace replaygain - report rewrite scrub smartplaylist sonosupdate spotify + stats subsonicplaylist subsonicupdate substitute diff --git a/docs/plugins/report.rst b/docs/plugins/report.rst deleted file mode 100644 index 92817922c..000000000 --- a/docs/plugins/report.rst +++ /dev/null @@ -1,62 +0,0 @@ -Report Plugin -============= - -The ``report`` plugin provides a command that generates a detailed statistical -summary of your music library. It collects information about tracks, albums, -artists, genres, years, formats, and more, giving you insights similar to a -“Wrapped” summary of your listening habits. - -First, enable the plugin named ``report`` (see :ref:`using-plugins`). You'll -then be able to use the ``beet report`` command: - -:: - - $ beet report - Beets Library Report - Generated: 2026-01-05 12:34:56 - ============================================================ - Overview - Tracks: 124 - Albums: 30 - Artists: 20 - Genres: 12 - Years: 1998 – 2022 - ------------------------------------------------------------ - Listening time & quality - Total playtime: 12:34:56 - Avg track length: 00:06:07 - Avg bitrate: 320 kbps (High quality) - Primary format: mp3 - ------------------------------------------------------------ - Favorite musical decades - 90s (1990-1999): 35 tracks (28.2%) - 00s (2000-2009): 40 tracks (32.3%) - 10s (2010-2019): 49 tracks (39.5%) - ------------------------------------------------------------ - Your Music Wrapped - Top artist: Radiohead (15 tracks) - Top genre: Alternative (28 tracks) - Top decade: 10s (2010-2019, 49 tracks) - Top year: 2017 (12 tracks) - Longest track: Pink Floyd – Echoes (23:31) - Shortest track: Daft Punk – Nightvision (01:12) - New music (2015+): 60 - Older music: 64 - Missing genre tags: 3 - Missing year tags: 2 - -The command takes no additional arguments. It scans your library and prints -statistics such as: - -- Total number of tracks, albums, artists, and genres -- Range of years present in the library -- Total listening time and average track length -- Average bitrate and primary file format -- Distribution of tracks by decade -- Most common artist, genre, decade, and year -- Longest and shortest tracks -- Counts of new vs. older music (tracks since 2015) -- Number of tracks missing genre or year tags - -This plugin is useful for analyzing your collection, identifying missing -metadata, and discovering trends in your listening habits. diff --git a/docs/plugins/stats.rst b/docs/plugins/stats.rst new file mode 100644 index 000000000..eb66880c3 --- /dev/null +++ b/docs/plugins/stats.rst @@ -0,0 +1,107 @@ +Stats Plugin +============ + +The ``stats`` plugin provides commands for displaying statistics about your +music library, such as the total number of tracks, artists, albums, and the +overall size and duration of your collection. + +Basic Statistics +---------------- + +By default, the ``stats`` command prints a concise summary of the library or a +query result: + +:: + + $ beet stats + +This includes: + +- Total number of matched tracks +- Total listening time +- Approximate total size of audio files +- Number of artists, albums, and album artists + +Exact Mode +---------- + +The ``-e`` / ``--exact`` flag enables *exact* size and duration calculations: + +:: + + $ beet stats --exact + +When this flag is used, the command: + +- Computes file sizes directly from the filesystem instead of estimating them + from bitrate and duration +- Prints both human-readable values and raw byte/second counts +- May be slower on large libraries, as it requires accessing every audio file + +This mode is useful when precise storage or duration figures are required. + +Overview Report +--------------- + +In addition to the standard output, the ``stats`` command supports an +*overview-style* report that generates a detailed, human-readable summary of +your library. + +To generate this report, run: + +:: + + $ beet stats --overview + +This prints a comprehensive report including general library statistics, +listening time, audio quality information, decade distribution, and summary +highlights. + +Example output: + +:: + + Beets Library Report + Generated: 2026-01-05 12:34:56 + ============================================================ + Overview + Tracks: 124 + Albums: 30 + Artists: 20 + Genres: 12 + Years: 1998 – 2022 + ------------------------------------------------------------ + Listening time & quality + Total playtime: 12:34:56 + Avg track length: 00:06:07 + Avg bitrate: 320 kbps (High quality) + Primary format: MP3 + ------------------------------------------------------------ + Favorite musical decades + 90s (1990-1999): 35 tracks (28.2%) + 00s (2000-2009): 40 tracks (32.3%) + 10s (2010-2019): 49 tracks (39.5%) + ------------------------------------------------------------ + Your Music Wrapped + Top artist: Radiohead (15 tracks) + Top genre: Alternative (28 tracks) + Top decade: 10s (2010-2019, 49 tracks) + Top year: 2017 (12 tracks) + Longest track: Pink Floyd – Echoes (23:31) + Shortest track: Daft Punk – Nightvision (01:12) + Missing genre tags: 3 + Missing year tags: 2 + +The overview report includes: + +- Total number of tracks, albums, artists, and genres +- Range of years present in the library +- Total listening time and average track length +- Average bitrate and primary file format +- Distribution of tracks by decade +- Most common artist, genre, decade, and year +- Longest and shortest tracks +- Counts of tracks missing genre or year metadata + +The ``--overview`` flag is mutually exclusive with ``--exact`` and always uses +estimated sizes and durations derived from metadata. diff --git a/test/test_report.py b/test/test_report.py deleted file mode 100644 index 2fdefab3b..000000000 --- a/test/test_report.py +++ /dev/null @@ -1,204 +0,0 @@ -# This file is part of beets. -# -# Permission is hereby granted, free of charge, to any person obtaining -# a copy of this software and associated documentation files (the -# "Software"), to deal in the Software without restriction, including -# without limitation the rights to use, copy, modify, merge, publish, -# distribute, sublicense, and/or sell copies of the Software, and to -# permit persons to whom the Software is furnished to do so, subject to -# the following conditions: -# -# The above copyright notice and this permission notice shall be -# included in all copies or substantial portions of the Software. - -import pytest - -from beets.library import Item -from beetsplug.report import ReportPlugin - - -# --- Fixtures --- -@pytest.fixture -def library(tmp_path): - """Create a temporary empty Beets library.""" - from beets.library import Library - - lib_path = tmp_path / "beets.db" - lib = Library(str(lib_path)) - return lib - - -def add_item( - lib, - title="Test", - artist="Artist", - album="Album", - genre="Genre", - year=2000, - length=180, - bitrate=320000, # bitrate in bits per second - format="MP3", -): - """Add a single Item to the test library.""" - item = Item( - path=f"/tmp/{title}.mp3", - title=title, - artist=artist, - album=album, - genre=genre, - year=year, - length=length, - bitrate=bitrate, - format=format, - ) - lib.add(item) - - -# --- Tests --- -def test_empty_library(capsys, library): - """Test empty library: should output message without crashing.""" - plugin = ReportPlugin() - plugin._run_report(library, None, []) - captured = capsys.readouterr() - assert "Your Beets library is empty." in captured.out - - -def test_single_item(capsys, library): - """Test library with a single track, including bitrate/quality and format.""" - add_item( - library, - title="Single Track", - artist="Solo Artist", - genre="Indie", - year=2019, - length=240, - bitrate=256000, # 256 kbps - format="MP3", - ) - plugin = ReportPlugin() - plugin._run_report(library, None, []) - captured = capsys.readouterr() - - # --- Basic statistics --- - assert "Tracks:" in captured.out - assert "Albums:" in captured.out - assert "Artists:" in captured.out - assert "Genres:" in captured.out - - # --- Wrapped-style insights --- - assert "Top artist:" in captured.out - assert "Solo Artist" in captured.out - assert "Top genre:" in captured.out - assert "Indie" in captured.out - assert "Top decade:" in captured.out - assert "10s" in captured.out - assert "Top year:" in captured.out - assert "2019" in captured.out - - # --- Bitrate / quality --- - avg_bitrate_lines = [ - line - for line in captured.out.splitlines() - if line.strip().startswith("Avg bitrate:") - ] - assert avg_bitrate_lines, "Expected an 'Avg bitrate:' line in output" - avg_line = avg_bitrate_lines[0] - assert "kbps" in avg_line - assert "(" in avg_line - assert ")" in avg_line # Quality label - - # --- Primary format --- - primary_format_lines = [ - line - for line in captured.out.splitlines() - if line.strip().startswith("Primary format:") - ] - assert primary_format_lines, "Expected a 'Primary format:' line in output" - primary_line = primary_format_lines[0] - assert "MP3" in primary_line - - -def test_multiple_items(capsys, library): - """Test library with multiple tracks from different decades and genres.""" - add_item(library, "Track1", "Artist A", "Album X", "Rock", 1995) - add_item(library, "Track2", "Artist A", "Album X", "Rock", 1995) - add_item(library, "Track3", "Artist B", "Album Y", "Pop", 2002) - add_item(library, "Track4", "Artist C", "Album Z", "Electronic", 2018) - - plugin = ReportPlugin() - plugin._run_report(library, None, []) - captured = capsys.readouterr() - - # --- Basic stats --- - assert "Tracks:" in captured.out - assert "4" in captured.out - assert "Albums:" in captured.out - assert "3" in captured.out - assert "Artists:" in captured.out - assert "3" in captured.out - assert "Genres:" in captured.out - assert "3" in captured.out - - # --- Wrapped-style insights --- - assert "Top artist:" in captured.out - assert "Artist A" in captured.out - assert "Top genre:" in captured.out - assert "Rock" in captured.out - assert "Top decade:" in captured.out - assert "90s" in captured.out - assert "Top year:" in captured.out - assert "1995" in captured.out - - # --- Decade distribution --- - assert "90s" in captured.out - assert "00s" in captured.out - assert "10s" in captured.out - - -def test_missing_metadata(capsys, library): - """Test library with missing tags, length, and bitrate.""" - add_item( - library, - "Track1", - "Artist", - "Album", - None, # missing genre - 2000, - length=200, - bitrate=256000, - ) - add_item( - library, - "Track2", - "Artist", - "Album", - "Rock", - None, # missing year - length=180, - bitrate=None, - ) - - plugin = ReportPlugin() - plugin._run_report(library, None, []) - captured = capsys.readouterr() - - # --- Check missing metadata counts --- - lines = captured.out.splitlines() - - # Check for missing genre - genre_found = False - for line in lines: - if "Missing genre tags:" in line: - assert "1" in line - genre_found = True - break - assert genre_found - - # Check for missing year - year_found = False - for line in lines: - if "Missing year tags:" in line: - assert "1" in line - year_found = True - break - assert year_found diff --git a/test/test_stats_overview.py b/test/test_stats_overview.py new file mode 100644 index 000000000..a19713e82 --- /dev/null +++ b/test/test_stats_overview.py @@ -0,0 +1,203 @@ +# This file is part of beets. +# +# Permission is hereby granted, free of charge, to any person obtaining +# a copy of this software and associated documentation files (the +# "Software"), to deal in the Software without restriction, including +# without limitation the rights to use, copy, modify, merge, publish, +# distribute, sublicense, and/or sell copies of the Software, and to +# permit persons to whom the Software is furnished to do so, subject to +# the following conditions: +# +# The above copyright notice and this permission notice shall be +# included in all copies or substantial portions of the Software. + +import pytest + +from beets.library import Item +from beets.ui.commands.stats import show_overview_report + +# --- Fixtures --- + + +@pytest.fixture +def library(tmp_path): + """Create a temporary empty Beets library.""" + from beets.library import Library + + lib_path = tmp_path / "beets.db" + lib = Library(str(lib_path)) + return lib + + +def add_item( + lib, + title="Test", + artist="Artist", + album="Album", + genre="Genre", + year=2000, + length=180, + bitrate=320000, + format="MP3", +): + """Add a single Item to the test library.""" + item = Item( + path=f"/tmp/{title}.mp3", + title=title, + artist=artist, + album=album, + genre=genre, + year=year, + length=length, + bitrate=bitrate, + format=format, + ) + lib.add(item) + + +# --- Tests --- + + +def test_empty_library_overview(capsys, library): + """Test empty library with overview report.""" + from beets.ui import _raw_main + + # Simulate: beet stats --overview + _raw_main(["stats", "--overview"], library) + captured = capsys.readouterr() + assert "Your Beets library is empty." in captured.out + + +def test_single_item_overview(capsys, library): + """Test library with a single track using overview report.""" + add_item( + library, + title="Single Track", + artist="Solo Artist", + genre="Indie", + year=2019, + bitrate=256000, + ) + + from beets.ui import _raw_main + + # Simulate: beet stats --overview + _raw_main(["stats", "--overview"], library) + captured = capsys.readouterr() + + # --- Check basic statistics --- + # Format is "Tracks: X" (3 spaces after colon) + assert "Tracks: 1" in captured.out + assert "Albums: 1" in captured.out + assert "Artists: 1" in captured.out + assert "Genres: 1" in captured.out + + # --- Wrapped-style insights --- + assert "Top artist: Solo Artist (1 tracks)" in captured.out + assert "Top genre: Indie (1 tracks)" in captured.out + + # Decade format: "10s (2010-2019): 1 tracks (100.0%)" + assert "10s (2010-2019" in captured.out + assert "1 tracks" in captured.out + + # Year format + assert "Top year: 2019 (1 tracks)" in captured.out + + +def test_multiple_items_overview(capsys, library): + """Test library with multiple tracks using overview report.""" + # 1995 – 2 tracks Rock + add_item(library, "Track1", "Artist A", "Album X", "Rock", 1995) + add_item(library, "Track2", "Artist A", "Album X", "Rock", 1995) + + # 2002 – 1 track Pop + add_item(library, "Track3", "Artist B", "Album Y", "Pop", 2002) + + # 2018 – 1 track Electronic + add_item(library, "Track4", "Artist C", "Album Z", "Electronic", 2018) + + from beets.ui import _raw_main + + # Simulate: beet stats --overview + _raw_main(["stats", "--overview"], library) + captured = capsys.readouterr() + + # --- Basic stats --- + assert "Tracks: 4" in captured.out + assert "Albums: 3" in captured.out + assert "Artists: 3" in captured.out + assert "Genres: 3" in captured.out + + # --- Wrapped insights --- + assert "Top artist: Artist A (2 tracks)" in captured.out + assert "Top genre: Rock (2 tracks)" in captured.out + + # Decade format check + assert "90s (1990-1999" in captured.out + assert "2 tracks" in captured.out + + # Year format + assert "Top year: 1995 (2 tracks)" in captured.out + + # --- Decade distribution --- + assert "90s (1990-1999" in captured.out + assert "00s (2000-2009" in captured.out + assert "10s (2010-2019" in captured.out + + +def test_missing_metadata_overview(capsys, library): + """Test library with missing tags using overview report.""" + # Missing genre + add_item( + library, + "Track1", + "Artist", + "Album", + None, + 2000, + length=200, + bitrate=256000, + ) + # Missing year + add_item( + library, + "Track2", + "Artist", + "Album", + "Rock", + None, + length=180, + bitrate=256000, + ) + + from beets.ui import _raw_main + + # Simulate: beet stats --overview + _raw_main(["stats", "--overview"], library) + captured = capsys.readouterr() + + # Format has 2 spaces after colon for year tags + assert "Missing genre tags: 1" in captured.out + assert "Missing year tags: 1" in captured.out + + +def test_various_lengths_and_bitrates_overview(capsys, library): + """Test track lengths and bitrate classification.""" + add_item(library, "Short", "A", "X", "Pop", 2010, length=60, bitrate=128000) + add_item( + library, "Long", "B", "Y", "Rock", 2015, length=3600, bitrate=1024000 + ) + + from beets.ui import _raw_main + + # Simulate: beet stats --overview + _raw_main(["stats", "--overview"], library) + captured = capsys.readouterr() + + # --- Check durations --- + assert "Total playtime:" in captured.out + assert "Avg track length:" in captured.out + + # --- Check bitrate and quality classification --- + assert "Avg bitrate:" in captured.out + assert "kbps" in captured.out From c34d802e5cf3dbca9f0037356c5c627e32626a47 Mon Sep 17 00:00:00 2001 From: MatMacinf Date: Mon, 5 Jan 2026 20:32:32 +0100 Subject: [PATCH 10/12] Fixed linting in tests and documentation --- docs/changelog.rst | 2 +- test/test_stats_overview.py | 39 ++++++++++++++++--------------------- 2 files changed, 18 insertions(+), 23 deletions(-) diff --git a/docs/changelog.rst b/docs/changelog.rst index e15b2a781..cccf08bb3 100644 --- a/docs/changelog.rst +++ b/docs/changelog.rst @@ -12,7 +12,7 @@ been dropped. New features: -- :doc:`beets/ui/commands/stats`: Extended the ``stats`` command with an +- :doc:`beets/ui/commands/stats.py`: Extended the ``stats`` command with an overview-style report (``--overview``) that generates a detailed statistical summary of the music library, including tracks, albums, artists, genres, years, listening time, audio quality, decade distribution, top diff --git a/test/test_stats_overview.py b/test/test_stats_overview.py index a19713e82..17fe889c5 100644 --- a/test/test_stats_overview.py +++ b/test/test_stats_overview.py @@ -12,9 +12,9 @@ # included in all copies or substantial portions of the Software. import pytest - +from beets import ui from beets.library import Item -from beets.ui.commands.stats import show_overview_report + # --- Fixtures --- @@ -55,21 +55,22 @@ def add_item( lib.add(item) -# --- Tests --- +# --- Tests for show_overview_report --- def test_empty_library_overview(capsys, library): """Test empty library with overview report.""" - from beets.ui import _raw_main + from beets.ui.commands.stats import show_overview_report - # Simulate: beet stats --overview - _raw_main(["stats", "--overview"], library) + show_overview_report(library, []) captured = capsys.readouterr() assert "Your Beets library is empty." in captured.out def test_single_item_overview(capsys, library): """Test library with a single track using overview report.""" + from beets.ui.commands.stats import show_overview_report + add_item( library, title="Single Track", @@ -79,10 +80,7 @@ def test_single_item_overview(capsys, library): bitrate=256000, ) - from beets.ui import _raw_main - - # Simulate: beet stats --overview - _raw_main(["stats", "--overview"], library) + show_overview_report(library, []) captured = capsys.readouterr() # --- Check basic statistics --- @@ -106,6 +104,8 @@ def test_single_item_overview(capsys, library): def test_multiple_items_overview(capsys, library): """Test library with multiple tracks using overview report.""" + from beets.ui.commands.stats import show_overview_report + # 1995 – 2 tracks Rock add_item(library, "Track1", "Artist A", "Album X", "Rock", 1995) add_item(library, "Track2", "Artist A", "Album X", "Rock", 1995) @@ -116,10 +116,7 @@ def test_multiple_items_overview(capsys, library): # 2018 – 1 track Electronic add_item(library, "Track4", "Artist C", "Album Z", "Electronic", 2018) - from beets.ui import _raw_main - - # Simulate: beet stats --overview - _raw_main(["stats", "--overview"], library) + show_overview_report(library, []) captured = capsys.readouterr() # --- Basic stats --- @@ -147,6 +144,8 @@ def test_multiple_items_overview(capsys, library): def test_missing_metadata_overview(capsys, library): """Test library with missing tags using overview report.""" + from beets.ui.commands.stats import show_overview_report + # Missing genre add_item( library, @@ -170,10 +169,7 @@ def test_missing_metadata_overview(capsys, library): bitrate=256000, ) - from beets.ui import _raw_main - - # Simulate: beet stats --overview - _raw_main(["stats", "--overview"], library) + show_overview_report(library, []) captured = capsys.readouterr() # Format has 2 spaces after colon for year tags @@ -183,15 +179,14 @@ def test_missing_metadata_overview(capsys, library): def test_various_lengths_and_bitrates_overview(capsys, library): """Test track lengths and bitrate classification.""" + from beets.ui.commands.stats import show_overview_report + add_item(library, "Short", "A", "X", "Pop", 2010, length=60, bitrate=128000) add_item( library, "Long", "B", "Y", "Rock", 2015, length=3600, bitrate=1024000 ) - from beets.ui import _raw_main - - # Simulate: beet stats --overview - _raw_main(["stats", "--overview"], library) + show_overview_report(library, []) captured = capsys.readouterr() # --- Check durations --- From 678df93eea8b91919c95c6d1e7e75323a914f484 Mon Sep 17 00:00:00 2001 From: MatMacinf Date: Mon, 5 Jan 2026 20:38:55 +0100 Subject: [PATCH 11/12] Fixed linting and chanelog entry --- docs/changelog.rst | 10 +++++----- test/test_stats_overview.py | 13 +++---------- 2 files changed, 8 insertions(+), 15 deletions(-) diff --git a/docs/changelog.rst b/docs/changelog.rst index cccf08bb3..6cde7253e 100644 --- a/docs/changelog.rst +++ b/docs/changelog.rst @@ -12,11 +12,11 @@ been dropped. New features: -- :doc:`beets/ui/commands/stats.py`: Extended the ``stats`` command with an - overview-style report (``--overview``) that generates a detailed statistical - summary of the music library, including tracks, albums, artists, genres, - years, listening time, audio quality, decade distribution, top - artist/genre/year, longest/shortest tracks, and counts of missing metadata. +- :doc:`plugins/stats`: Extended the ``stats`` command with an overview-style + report (``--overview``) that generates a detailed statistical summary of the + music library, including tracks, albums, artists, genres, years, listening + time, audio quality, decade distribution, top artist/genre/year, + longest/shortest tracks, and counts of missing metadata. - :doc:`plugins/fetchart`: Added config setting for a fallback cover art image. - :doc:`plugins/ftintitle`: Added argument for custom feat. words in ftintitle. - :doc:`plugins/ftintitle`: Added album template value ``album_artist_no_feat``. diff --git a/test/test_stats_overview.py b/test/test_stats_overview.py index 17fe889c5..67d77439b 100644 --- a/test/test_stats_overview.py +++ b/test/test_stats_overview.py @@ -12,9 +12,10 @@ # included in all copies or substantial portions of the Software. import pytest -from beets import ui -from beets.library import Item +from beets.ui.commands.stats import show_overview_report +from beets.library import Item +from beets.library import Library # --- Fixtures --- @@ -22,8 +23,6 @@ from beets.library import Item @pytest.fixture def library(tmp_path): """Create a temporary empty Beets library.""" - from beets.library import Library - lib_path = tmp_path / "beets.db" lib = Library(str(lib_path)) return lib @@ -60,8 +59,6 @@ def add_item( def test_empty_library_overview(capsys, library): """Test empty library with overview report.""" - from beets.ui.commands.stats import show_overview_report - show_overview_report(library, []) captured = capsys.readouterr() assert "Your Beets library is empty." in captured.out @@ -69,7 +66,6 @@ def test_empty_library_overview(capsys, library): def test_single_item_overview(capsys, library): """Test library with a single track using overview report.""" - from beets.ui.commands.stats import show_overview_report add_item( library, @@ -104,7 +100,6 @@ def test_single_item_overview(capsys, library): def test_multiple_items_overview(capsys, library): """Test library with multiple tracks using overview report.""" - from beets.ui.commands.stats import show_overview_report # 1995 – 2 tracks Rock add_item(library, "Track1", "Artist A", "Album X", "Rock", 1995) @@ -144,7 +139,6 @@ def test_multiple_items_overview(capsys, library): def test_missing_metadata_overview(capsys, library): """Test library with missing tags using overview report.""" - from beets.ui.commands.stats import show_overview_report # Missing genre add_item( @@ -179,7 +173,6 @@ def test_missing_metadata_overview(capsys, library): def test_various_lengths_and_bitrates_overview(capsys, library): """Test track lengths and bitrate classification.""" - from beets.ui.commands.stats import show_overview_report add_item(library, "Short", "A", "X", "Pop", 2010, length=60, bitrate=128000) add_item( From ed859c387dcdcce8cd8019e55342fb97c6deb938 Mon Sep 17 00:00:00 2001 From: MatMacinf Date: Mon, 5 Jan 2026 20:44:08 +0100 Subject: [PATCH 12/12] Sorted imports in test --- test/{ => plugins}/test_stats_overview.py | 6 ++---- 1 file changed, 2 insertions(+), 4 deletions(-) rename test/{ => plugins}/test_stats_overview.py (98%) diff --git a/test/test_stats_overview.py b/test/plugins/test_stats_overview.py similarity index 98% rename from test/test_stats_overview.py rename to test/plugins/test_stats_overview.py index 67d77439b..19132b58d 100644 --- a/test/test_stats_overview.py +++ b/test/plugins/test_stats_overview.py @@ -13,13 +13,11 @@ import pytest +from beets.library import Item, Library from beets.ui.commands.stats import show_overview_report -from beets.library import Item -from beets.library import Library + # --- Fixtures --- - - @pytest.fixture def library(tmp_path): """Create a temporary empty Beets library."""