diff --git a/beets/random.py b/beets/random.py deleted file mode 100644 index f3318054c..000000000 --- a/beets/random.py +++ /dev/null @@ -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) diff --git a/beetsplug/report.py b/beetsplug/report.py new file mode 100644 index 000000000..13ab91375 --- /dev/null +++ b/beetsplug/report.py @@ -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.") \ No newline at end of file diff --git a/test/test_report.py b/test/test_report.py new file mode 100644 index 000000000..a99fa5c0e --- /dev/null +++ b/test/test_report.py @@ -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