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