Add report plugin with test and documentation

This commit is contained in:
MatMacinf 2026-01-05 11:52:59 +01:00
parent 4af3ec3b29
commit 3ad21629ae
6 changed files with 313 additions and 69 deletions

16
beets/.zshrc Normal file
View file

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

112
beets/random_utils.py Normal file
View file

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

View file

@ -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.")
print_("\nReport complete.")

View file

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

62
docs/plugins/report.rst Normal file
View file

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

View file

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