diff --git a/beets/.zshrc b/beets/.zshrc new file mode 100644 index 000000000..1bce84375 --- /dev/null +++ b/beets/.zshrc @@ -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" diff --git a/beets/random_utils.py b/beets/random_utils.py new file mode 100644 index 000000000..f3318054c --- /dev/null +++ b/beets/random_utils.py @@ -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) diff --git a/beetsplug/report.py b/beetsplug/report.py index 13ab91375..9466c76a8 100644 --- a/beetsplug/report.py +++ b/beetsplug/report.py @@ -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.") \ No newline at end of file + print_("\nReport complete.") diff --git a/docs/changelog.rst b/docs/changelog.rst index b9e21aae9..09a6bd19e 100644 --- a/docs/changelog.rst +++ b/docs/changelog.rst @@ -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``. diff --git a/docs/plugins/report.rst b/docs/plugins/report.rst new file mode 100644 index 000000000..92817922c --- /dev/null +++ b/docs/plugins/report.rst @@ -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. diff --git a/test/test_report.py b/test/test_report.py index a99fa5c0e..7597952c2 100644 --- a/test/test_report.py +++ b/test/test_report.py @@ -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