-
Notifications
You must be signed in to change notification settings - Fork 2k
Add report plugin with library wrapped like statistics #6266
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: master
Are you sure you want to change the base?
Changes from 4 commits
4af3ec3
3ad2162
17e8fd1
99a9588
edc09b4
3694b82
2063631
91d827f
8aa96e8
c34d802
678df93
ed859c3
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| 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.""" | ||
|
|
||
| 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.") | ||
| 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. |
| 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 | ||
|
|
||
| # --- 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() | ||
|
|
||
| # --- 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 | ||
| 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-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" 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, | ||
|
||
| 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 | ||
Uh oh!
There was an error while loading. Please reload this page.