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/docs/changelog.rst b/docs/changelog.rst index 25a0c1365..c49a93717 100644 --- a/docs/changelog.rst +++ b/docs/changelog.rst @@ -30,6 +30,11 @@ been dropped. New features: +- :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/docs/plugins/index.rst b/docs/plugins/index.rst index 1583ac5ab..6a61d2198 100644 --- a/docs/plugins/index.rst +++ b/docs/plugins/index.rst @@ -122,6 +122,7 @@ databases. They share the following configuration options: smartplaylist sonosupdate spotify + stats subsonicplaylist subsonicupdate substitute 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/plugins/test_stats_overview.py b/test/plugins/test_stats_overview.py new file mode 100644 index 000000000..19132b58d --- /dev/null +++ b/test/plugins/test_stats_overview.py @@ -0,0 +1,189 @@ +# 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, Library +from beets.ui.commands.stats import show_overview_report + + +# --- Fixtures --- +@pytest.fixture +def library(tmp_path): + """Create a temporary empty Beets 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 for show_overview_report --- + + +def test_empty_library_overview(capsys, library): + """Test empty library with overview report.""" + 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.""" + + add_item( + library, + title="Single Track", + artist="Solo Artist", + genre="Indie", + year=2019, + bitrate=256000, + ) + + show_overview_report(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) + + show_overview_report(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, + ) + + show_overview_report(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 + ) + + show_overview_report(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