This commit is contained in:
MatMacinf 2026-02-02 17:43:00 +01:00 committed by GitHub
commit 7ebc2c1217
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
5 changed files with 478 additions and 1 deletions

View file

@ -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

View file

@ -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``.

View file

@ -122,6 +122,7 @@ databases. They share the following configuration options:
smartplaylist
sonosupdate
spotify
stats
subsonicplaylist
subsonicupdate
substitute

107
docs/plugins/stats.rst Normal file
View file

@ -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.

View file

@ -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