mirror of
https://github.com/beetbox/beets.git
synced 2026-02-09 00:41:57 +01:00
Added new plugin with tests
This commit is contained in:
parent
ea2e7bf997
commit
4af3ec3b29
3 changed files with 272 additions and 112 deletions
112
beets/random.py
112
beets/random.py
|
|
@ -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
153
beetsplug/report.py
Normal 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
119
test/test_report.py
Normal 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
|
||||
Loading…
Reference in a new issue