mirror of
https://github.com/beetbox/beets.git
synced 2026-02-09 00:41:57 +01:00
Add report plugin with test and documentation
This commit is contained in:
parent
4af3ec3b29
commit
3ad21629ae
6 changed files with 313 additions and 69 deletions
16
beets/.zshrc
Normal file
16
beets/.zshrc
Normal 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
112
beets/random_utils.py
Normal 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)
|
||||
|
|
@ -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.")
|
||||
|
|
|
|||
|
|
@ -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
62
docs/plugins/report.rst
Normal 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.
|
||||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Reference in a new issue