Skip to content
Open
179 changes: 179 additions & 0 deletions beetsplug/report.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,179 @@
# 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 statistics and Wrapped-style insights."""

Check failure on line 24 in beetsplug/report.py

View workflow job for this annotation

GitHub Actions / Check linting

Ruff (E501)

beetsplug/report.py:24:89: E501 Line too long (100 > 88)

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):
"""Collect statistics and print a report about the library."""
items = list(lib.items())
total_tracks = len(items)

if total_tracks == 0:
print_("Your Beets library is empty.")
return

# --- Collect metadata ---
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
]
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]

# --- Counters ---
artist_counter = Counter(artists)
genre_counter = Counter(genres)
format_counter = Counter(formats)
year_counter = Counter(years)

# --- Time calculations ---
total_length = sum(lengths) if lengths else 0
avg_length = total_length / len(lengths) if lengths else 0

def fmt_time(seconds):
return str(datetime.timedelta(seconds=int(seconds)))

# --- Decades ---
current_year = datetime.datetime.now().year
decades = [(y // 10) * 10 for y in years if 1900 <= y <= current_year]
decade_counter = Counter(decades)

def decade_label(d):
return f"{str(d)[-2:]}s"

# --- Wrapped insights ---
top_artist = artist_counter.most_common(1)
top_genre = genre_counter.most_common(1)
top_decade = decade_counter.most_common(1)
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
)

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
)

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

# ===================== 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: {total_tracks}")
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_("-" * 60)

# --- Duration & quality ---
print_("Listening time & quality")
print_(f" Total playtime: {fmt_time(total_length)}")
print_(f" Avg track length: {fmt_time(avg_length)}")
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_("-" * 60)

# --- Decade distribution ---
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
print_(
f" {decade_label(d):>4} ({d}-{d + 9}): {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]} ({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)")

print_(
f" Longest track: {longest_track.artist} – {longest_track.title} ({fmt_time(longest_track.length)})"

Check failure on line 166 in beetsplug/report.py

View workflow job for this annotation

GitHub Actions / Check linting

Ruff (E501)

beetsplug/report.py:166:89: E501 Line too long (114 > 88)
)
if shortest_track:
print_(
f" Shortest track: {shortest_track.artist} – {shortest_track.title} ({fmt_time(shortest_track.length)})"

Check failure on line 170 in beetsplug/report.py

View workflow job for this annotation

GitHub Actions / Check linting

Ruff (E501)

beetsplug/report.py:170:89: E501 Line too long (121 > 88)
)

print_(f" New music (2015+): {recent_tracks}")
print_(f" Older music: {older_tracks}")

print_(f" Missing genre tags: {missing_genre}")
print_(f" Missing year tags: {missing_year}")

print_("\nReport complete.")
4 changes: 4 additions & 0 deletions docs/changelog.rst
Original file line number Diff line number Diff line change
Expand Up @@ -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``.
Expand Down
62 changes: 62 additions & 0 deletions docs/plugins/report.rst
Original file line number Diff line number Diff line change
@@ -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.
143 changes: 143 additions & 0 deletions test/test_report.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,143 @@
import pytest
from beets.library import Item
from beetsplug.report import ReportPlugin

Check failure on line 3 in test/test_report.py

View workflow job for this annotation

GitHub Actions / Check linting

Ruff (I001)

test/test_report.py:1:1: I001 Import block is un-sorted or un-formatted

# --- 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,
):
"""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,
)
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."""
add_item(
library,
title="Single Track",
artist="Solo Artist",
genre="Indie",
year=2019,
)
plugin = ReportPlugin()
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

suggestion (testing): Add coverage for bitrate/quality and primary format output in the report.

This currently checks basic stats and Wrapped-style insights, but not the bitrate/quality and primary format output, which is central to this plugin. Please extend this (or add another test) to:

  • Set a specific bitrate and format on the item.
  • Assert that the report includes the expected Avg bitrate: ... kbps line with the correct quality label.
  • Assert that the Primary format: line is present and matches the item’s format.

This will ensure the bitrate aggregation and quality/primary-format logic are properly covered.

Suggested implementation:

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."""
    # Create a single item with explicit bitrate and format so we can
    # exercise the bitrate/quality and primary-format reporting.
    add_item(
        library,
        title="Single Track",
        artist="Solo Artist",
        genre="Indie",
        year=2019,
        # Beets stores bitrate as bits per second; 256 kbps == 256000 bps.
        bitrate=256000,
        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 statistics ---
    # Find the "Avg bitrate" line so we can assert both the numeric
    # value and presence of a quality label (typically in parentheses).
    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]

    # Should include a kbps value.
    assert "kbps" in avg_line

    # Should include a human-readable quality label (e.g. '(High)', '(Lossless)', etc.).
    # We don't depend on the exact wording, just that a label is present in parentheses.
    assert "(" in avg_line and ")" in avg_line

    # --- Primary format statistics ---
    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

This patch assumes that:

  1. add_item(...) accepts bitrate and format as keyword arguments and sets the corresponding fields on the created item.
  2. The report output contains a line starting with "Avg bitrate:" that includes a numeric value in kbps and a quality label inside parentheses on the same line.
  3. The report output contains a line starting with "Primary format:" that includes the canonical format string (e.g. "MP3").

If the actual output format or label placement differs (for example, the quality label is not in parentheses, or the format is lowercase like mp3), adjust the assertions on avg_line and primary_line accordingly to match the exact strings produced by ReportPlugin._run_report.

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


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 and "4" in captured.out

Check failure on line 95 in test/test_report.py

View workflow job for this annotation

GitHub Actions / Check linting

Ruff (PT018)

test/test_report.py:95:5: PT018 Assertion should be broken down into multiple parts
assert "Albums:" in captured.out and "3" in captured.out

Check failure on line 96 in test/test_report.py

View workflow job for this annotation

GitHub Actions / Check linting

Ruff (PT018)

test/test_report.py:96:5: PT018 Assertion should be broken down into multiple parts
assert "Artists:" in captured.out and "3" in captured.out

Check failure on line 97 in test/test_report.py

View workflow job for this annotation

GitHub Actions / Check linting

Ruff (PT018)

test/test_report.py:97:5: PT018 Assertion should be broken down into multiple parts
assert "Genres:" in captured.out and "3" in captured.out

Check failure on line 98 in test/test_report.py

View workflow job for this annotation

GitHub Actions / Check linting

Ruff (PT018)

test/test_report.py:98:5: PT018 Assertion should be broken down into multiple parts

# --- Wrapped-style insights ---
assert "Top artist:" in captured.out and "Artist A" in captured.out

Check failure on line 101 in test/test_report.py

View workflow job for this annotation

GitHub Actions / Check linting

Ruff (PT018)

test/test_report.py:101:5: PT018 Assertion should be broken down into multiple parts
assert "Top genre:" in captured.out and "Rock" in captured.out

Check failure on line 102 in test/test_report.py

View workflow job for this annotation

GitHub Actions / Check linting

Ruff (PT018)

test/test_report.py:102:5: PT018 Assertion should be broken down into multiple parts
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" 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,
2000,
length=200,
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

issue (testing): Make the assertions for missing metadata counts more specific to avoid false positives.

The bare "1" checks can match any occurrence of 1 in the output, so they don’t reliably verify the missing-metadata counters. Instead, assert on the full expected lines or a more specific substring, e.g.:

assert "Missing genre tags: 1" in captured.out
assert "Missing year tags: 1" in captured.out

(or a regex equivalent if the exact formatting might vary).

bitrate=256,
)
add_item(
library,
"Track2",
"Artist",
"Album",
"Rock",
None,
length=180,
bitrate=None,
)

plugin = ReportPlugin()
plugin._run_report(library, None, [])
captured = capsys.readouterr()

# --- 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
Loading