mirror of
https://github.com/beetbox/beets.git
synced 2026-02-09 08:52:30 +01:00
Merge ed859c387d into cdfb813910
This commit is contained in:
commit
7ebc2c1217
5 changed files with 478 additions and 1 deletions
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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``.
|
||||
|
|
|
|||
|
|
@ -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
107
docs/plugins/stats.rst
Normal 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.
|
||||
189
test/plugins/test_stats_overview.py
Normal file
189
test/plugins/test_stats_overview.py
Normal 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
|
||||
Loading…
Reference in a new issue