Added new plugin with tests

This commit is contained in:
MatMacinf 2026-01-05 11:07:54 +01:00
parent ea2e7bf997
commit 4af3ec3b29
3 changed files with 272 additions and 112 deletions

View file

@ -1,112 +0,0 @@
# 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)

153
beetsplug/report.py Normal file
View file

@ -0,0 +1,153 @@
# 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."""
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)})")
if shortest_track:
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}")
print_(f" Missing genre tags: {missing_genre}")
print_(f" Missing year tags: {missing_year}")
print_("\nReport complete.")

119
test/test_report.py Normal file
View file

@ -0,0 +1,119 @@
import pytest
from beets.library import Item
from beetsplug.report_plugin 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):
"""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()
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
# --- 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
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()
plugin._run_report(library, None, [])
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
assert "Top decade: 90s (1990-1999, 2 tracks)" in captured.out
assert "Top year: 1995 (2 tracks)" 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
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)
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