Add advanced features to Pomice

New Features:
- Track History: Keep track of previously played songs with navigation and search
- Queue Statistics: Detailed analytics about queue contents (duration, requesters, etc.)
- Playlist Manager: Export/import playlists to JSON and M3U formats
- Track Utilities: Advanced filtering, searching, and sorting capabilities

Added Files:
- pomice/history.py: Track history management system
- pomice/queue_stats.py: Queue statistics and analytics
- pomice/playlist_manager.py: Playlist export/import functionality
- pomice/track_utils.py: Track filtering and search utilities
- examples/advanced_features.py: Complete example bot demonstrating all features
- ADVANCED_FEATURES.md: Comprehensive documentation
- NEW_FEATURES_SUMMARY.md: Quick reference guide

All features are fully documented with examples and type hints.
No breaking changes to existing functionality.
This commit is contained in:
wizardoesmagic 2025-12-28 07:57:23 +00:00
parent b18719dc48
commit 0e7473a807
8 changed files with 2259 additions and 0 deletions

426
ADVANCED_FEATURES.md Normal file
View File

@ -0,0 +1,426 @@
# Pomice Advanced Features
This document describes the new advanced features added to Pomice to enhance your music bot capabilities.
## 📚 Table of Contents
1. [Track History](#track-history)
2. [Queue Statistics](#queue-statistics)
3. [Playlist Manager](#playlist-manager)
4. [Track Utilities](#track-utilities)
---
## 🕐 Track History
Keep track of previously played songs with navigation and search capabilities.
### Features
- Configurable maximum history size
- Navigation (previous/next)
- Search through history
- Filter by requester
- Get unique tracks (remove duplicates)
### Usage
```python
import pomice
# Create a history tracker
history = pomice.TrackHistory(max_size=100)
# Add tracks as they play
history.add(track)
# Get last 10 played tracks
recent = history.get_last(10)
# Search history
results = history.search("Imagine Dragons")
# Get tracks by specific user
user_tracks = history.get_by_requester(user_id=123456789)
# Navigate through history
previous_track = history.get_previous()
next_track = history.get_next()
# Get all unique tracks (removes duplicates)
unique = history.get_unique_tracks()
# Clear history
history.clear()
```
### Properties
- `is_empty` - Check if history is empty
- `current` - Get current track in navigation
---
## 📊 Queue Statistics
Get detailed analytics about your queue contents.
### Features
- Total and average duration
- Longest/shortest tracks
- Requester statistics
- Author distribution
- Duration breakdown
- Stream detection
- Playlist distribution
### Usage
```python
import pomice
# Create stats for a queue
stats = pomice.QueueStats(player.queue)
# Get total duration
total_ms = stats.total_duration
formatted = stats.format_duration(total_ms) # "1:23:45"
# Get average duration
avg_ms = stats.average_duration
# Find longest and shortest tracks
longest = stats.longest_track
shortest = stats.shortest_track
# Get requester statistics
requester_stats = stats.get_requester_stats()
# Returns: {user_id: {'count': 5, 'total_duration': 900000, 'tracks': [...]}}
# Get top requesters
top_requesters = stats.get_top_requesters(limit=5)
# Returns: [(requester, count), ...]
# Get author distribution
authors = stats.get_author_distribution()
# Returns: {'Artist Name': track_count, ...}
# Get top authors
top_authors = stats.get_top_authors(limit=10)
# Returns: [('Artist Name', count), ...]
# Get duration breakdown
breakdown = stats.get_duration_breakdown()
# Returns: {'short': 10, 'medium': 25, 'long': 5, 'very_long': 2}
# Get stream count
streams = stats.get_stream_count()
# Get comprehensive summary
summary = stats.get_summary()
```
### Summary Dictionary
```python
{
'total_tracks': 42,
'total_duration': 7200000, # milliseconds
'total_duration_formatted': '2:00:00',
'average_duration': 171428.57,
'average_duration_formatted': '2:51',
'longest_track': Track(...),
'shortest_track': Track(...),
'stream_count': 3,
'unique_authors': 15,
'unique_requesters': 5,
'duration_breakdown': {...},
'loop_mode': LoopMode.QUEUE,
'is_looping': True
}
```
---
## 💾 Playlist Manager
Export and import playlists to/from JSON and M3U formats.
### Features
- Export queue to JSON
- Import playlists from JSON
- Export to M3U format
- Merge multiple playlists
- Remove duplicates
- Playlist metadata
### Usage
#### Export Queue
```python
import pomice
# Export current queue
pomice.PlaylistManager.export_queue(
player.queue,
filepath='playlists/my_playlist.json',
name='My Awesome Playlist',
description='Best songs ever',
include_metadata=True # Include requester info
)
```
#### Import Playlist
```python
# Import playlist data
data = pomice.PlaylistManager.import_playlist('playlists/my_playlist.json')
# Get just the URIs
uris = pomice.PlaylistManager.get_track_uris('playlists/my_playlist.json')
# Load tracks into queue
for uri in uris:
results = await player.get_tracks(query=uri)
if results:
await player.queue.put(results[0])
```
#### Export Track List
```python
# Export a list of tracks (not from queue)
tracks = [track1, track2, track3]
pomice.PlaylistManager.export_track_list(
tracks,
filepath='playlists/favorites.json',
name='Favorites',
description='My favorite tracks'
)
```
#### Merge Playlists
```python
# Merge multiple playlists into one
pomice.PlaylistManager.merge_playlists(
filepaths=['playlist1.json', 'playlist2.json', 'playlist3.json'],
output_path='merged_playlist.json',
name='Mega Playlist',
remove_duplicates=True # Remove duplicate tracks
)
```
#### Export to M3U
```python
# Export to M3U format (compatible with many players)
tracks = list(player.queue)
pomice.PlaylistManager.export_to_m3u(
tracks,
filepath='playlists/my_playlist.m3u',
name='My Playlist'
)
```
#### Get Playlist Info
```python
# Get metadata without loading all tracks
info = pomice.PlaylistManager.get_playlist_info('playlists/my_playlist.json')
# Returns: {'name': '...', 'track_count': 42, 'total_duration': 7200000, ...}
```
### JSON Format
```json
{
"name": "My Playlist",
"description": "Best songs",
"created_at": "2024-01-15T12:30:00",
"track_count": 10,
"total_duration": 1800000,
"version": "1.0",
"tracks": [
{
"title": "Song Title",
"author": "Artist Name",
"uri": "https://...",
"identifier": "abc123",
"length": 180000,
"thumbnail": "https://...",
"isrc": "USRC12345678",
"requester_id": 123456789,
"requester_name": "User#1234"
}
]
}
```
---
## 🔧 Track Utilities
Advanced filtering, searching, and sorting utilities for tracks.
### TrackFilter
Filter tracks by various criteria.
```python
import pomice
tracks = list(player.queue)
# Filter by duration (milliseconds)
short_tracks = pomice.TrackFilter.by_duration(
tracks,
min_duration=60000, # 1 minute
max_duration=300000 # 5 minutes
)
# Filter by author
artist_tracks = pomice.TrackFilter.by_author(
tracks,
author='Imagine Dragons',
exact=False # Case-insensitive contains
)
# Filter by title
title_tracks = pomice.TrackFilter.by_title(
tracks,
title='Thunder',
exact=True # Exact match
)
# Filter by requester
user_tracks = pomice.TrackFilter.by_requester(tracks, requester_id=123456789)
# Filter by playlist
playlist_tracks = pomice.TrackFilter.by_playlist(tracks, playlist_name='Rock Hits')
# Get only streams
streams = pomice.TrackFilter.streams_only(tracks)
# Get only non-streams
non_streams = pomice.TrackFilter.non_streams_only(tracks)
# Custom filter with lambda
long_tracks = pomice.TrackFilter.custom(
tracks,
predicate=lambda t: t.length > 600000 # > 10 minutes
)
```
### SearchHelper
Search, sort, and organize tracks.
```python
import pomice
tracks = list(player.queue)
# Search tracks
results = pomice.SearchHelper.search_tracks(
tracks,
query='imagine',
search_title=True,
search_author=True,
case_sensitive=False
)
# Sort by duration
sorted_tracks = pomice.SearchHelper.sort_by_duration(
tracks,
reverse=True # Longest first
)
# Sort by title (alphabetically)
sorted_tracks = pomice.SearchHelper.sort_by_title(tracks)
# Sort by author
sorted_tracks = pomice.SearchHelper.sort_by_author(tracks)
# Remove duplicates
unique_tracks = pomice.SearchHelper.remove_duplicates(
tracks,
by_uri=True, # Remove by URI
by_title_author=False # Or by title+author combo
)
# Group by author
grouped = pomice.SearchHelper.group_by_author(tracks)
# Returns: {'Artist Name': [track1, track2, ...], ...}
# Group by playlist
grouped = pomice.SearchHelper.group_by_playlist(tracks)
# Get random tracks
random_tracks = pomice.SearchHelper.get_random_tracks(tracks, count=5)
```
---
## 🎯 Complete Example
See `examples/advanced_features.py` for a complete bot example using all these features.
### Quick Example
```python
import pomice
from discord.ext import commands
class Music(commands.Cog):
def __init__(self, bot):
self.bot = bot
self.history = pomice.TrackHistory(max_size=100)
@commands.command()
async def stats(self, ctx):
"""Show queue statistics."""
player = ctx.voice_client
stats = pomice.QueueStats(player.queue)
summary = stats.get_summary()
await ctx.send(
f"**Queue Stats**\n"
f"Tracks: {summary['total_tracks']}\n"
f"Duration: {summary['total_duration_formatted']}\n"
f"Streams: {summary['stream_count']}"
)
@commands.command()
async def export(self, ctx):
"""Export queue to file."""
player = ctx.voice_client
pomice.PlaylistManager.export_queue(
player.queue,
'my_playlist.json',
name=f"{ctx.guild.name}'s Queue"
)
await ctx.send('✅ Queue exported!')
@commands.command()
async def filter_long(self, ctx):
"""Show tracks longer than 5 minutes."""
player = ctx.voice_client
tracks = list(player.queue)
long_tracks = pomice.TrackFilter.by_duration(
tracks,
min_duration=300000 # 5 minutes
)
await ctx.send(f'Found {len(long_tracks)} long tracks!')
```
---
## 📝 Notes
- All duration values are in **milliseconds**
- History is per-guild (you should maintain separate histories for each guild)
- Exported playlists are in JSON format by default
- M3U export is compatible with most media players
- All utilities work with standard Pomice Track objects
## 🤝 Contributing
Feel free to suggest more features or improvements!
---
**Happy coding! 🎵**

301
NEW_FEATURES_SUMMARY.md Normal file
View File

@ -0,0 +1,301 @@
# Pomice Enhancement Summary
## 🎉 New Features Added
This update adds **4 major feature modules** to enhance Pomice's capabilities for building advanced music bots.
---
## 📦 New Modules
### 1. **Track History** (`pomice/history.py`)
- **Purpose**: Keep track of previously played songs
- **Key Features**:
- Configurable history size (default: 100 tracks)
- Navigation (previous/next track)
- Search through history by title/author
- Filter by requester
- Get unique tracks (removes duplicates)
- Get last N played tracks
- **Use Cases**:
- "What was that song that just played?"
- "Show me the last 10 songs"
- "Play the previous track"
- "Show all songs requested by User X"
### 2. **Queue Statistics** (`pomice/queue_stats.py`)
- **Purpose**: Detailed analytics about queue contents
- **Key Features**:
- Total and average duration calculations
- Find longest/shortest tracks
- Requester statistics (who added what)
- Author distribution (most common artists)
- Duration breakdown (short/medium/long/very long)
- Stream detection
- Playlist distribution
- Comprehensive summary with formatted output
- **Use Cases**:
- "How long is the queue?"
- "Who added the most songs?"
- "What's the longest track?"
- "Show me queue statistics"
### 3. **Playlist Manager** (`pomice/playlist_manager.py`)
- **Purpose**: Export and import playlists
- **Key Features**:
- Export queue to JSON format
- Import playlists from JSON
- Export to M3U format (universal compatibility)
- Merge multiple playlists
- Remove duplicates when merging
- Get playlist metadata without loading all tracks
- Export track lists (not just queues)
- **Use Cases**:
- "Save this queue for later"
- "Load my favorite playlist"
- "Merge all my playlists"
- "Export to M3U for my media player"
### 4. **Track Utilities** (`pomice/track_utils.py`)
- **Purpose**: Advanced filtering, searching, and sorting
- **Key Features**:
- **TrackFilter**:
- Filter by duration range
- Filter by author (exact or contains)
- Filter by title
- Filter by requester
- Filter by playlist
- Streams only / non-streams only
- Custom filter with lambda functions
- **SearchHelper**:
- Search tracks by query
- Sort by duration/title/author
- Remove duplicates (by URI or title+author)
- Group by author or playlist
- Get random tracks
- **Use Cases**:
- "Show me all songs by Artist X"
- "Find tracks between 3-5 minutes"
- "Sort queue by duration"
- "Remove duplicate songs"
- "Play 5 random tracks"
---
## 📁 Files Added
```
pomice/
├── history.py # Track history system
├── queue_stats.py # Queue statistics
├── playlist_manager.py # Playlist export/import
├── track_utils.py # Filtering and search utilities
└── __init__.py # Updated to export new modules
examples/
└── advanced_features.py # Complete example bot
ADVANCED_FEATURES.md # Comprehensive documentation
NEW_FEATURES_SUMMARY.md # This file
```
---
## 🚀 Quick Start
### Installation
The new features are automatically available when you import pomice:
```python
import pomice
# All new features are now available
history = pomice.TrackHistory()
stats = pomice.QueueStats(queue)
pomice.PlaylistManager.export_queue(...)
filtered = pomice.TrackFilter.by_author(tracks, "Artist")
```
### Basic Usage Example
```python
import pomice
from discord.ext import commands
class Music(commands.Cog):
def __init__(self, bot):
self.bot = bot
self.history = pomice.TrackHistory(max_size=100)
@commands.Cog.listener()
async def on_pomice_track_end(self, player, track, _):
# Add to history when track ends
self.history.add(track)
@commands.command()
async def stats(self, ctx):
"""Show queue statistics."""
stats = pomice.QueueStats(ctx.voice_client.queue)
summary = stats.get_summary()
await ctx.send(
f"**Queue Stats**\n"
f"📊 Tracks: {summary['total_tracks']}\n"
f"⏱️ Duration: {summary['total_duration_formatted']}\n"
f"📡 Streams: {summary['stream_count']}\n"
f"👥 Unique Requesters: {summary['unique_requesters']}"
)
@commands.command()
async def history(self, ctx, limit: int = 10):
"""Show recently played tracks."""
recent = self.history.get_last(limit)
tracks_list = '\n'.join(
f"{i}. {track.title} by {track.author}"
for i, track in enumerate(recent, 1)
)
await ctx.send(f"**Recently Played:**\n{tracks_list}")
@commands.command()
async def export(self, ctx):
"""Export current queue."""
pomice.PlaylistManager.export_queue(
ctx.voice_client.queue,
f'playlists/{ctx.guild.id}.json',
name=f"{ctx.guild.name}'s Queue"
)
await ctx.send('✅ Queue exported!')
```
---
## 📊 Statistics
- **Total Lines of Code**: ~1,200+ lines
- **New Classes**: 6 (TrackHistory, QueueStats, PlaylistManager, TrackFilter, SearchHelper)
- **New Methods**: 50+
- **Documentation**: Complete with examples
---
## 🎯 Benefits
1. **Enhanced User Experience**
- Users can see what played recently
- Detailed queue information
- Save and load playlists
2. **Better Bot Management**
- Track who's adding what
- Analyze queue patterns
- Filter and organize tracks efficiently
3. **Persistence**
- Save queues for later
- Share playlists between servers
- Export to universal formats (M3U)
4. **Flexibility**
- Custom filtering with lambdas
- Multiple sort options
- Comprehensive search capabilities
---
## 🔧 Compatibility
- ✅ **Fully compatible** with existing Pomice code
- ✅ **No breaking changes** to existing functionality
- ✅ **Optional features** - use what you need
- ✅ **Type hints** included for better IDE support
- ✅ **Documented** with docstrings and examples
---
## 📚 Documentation
- **Full Documentation**: See `ADVANCED_FEATURES.md`
- **Example Bot**: See `examples/advanced_features.py`
- **Inline Docs**: All functions have comprehensive docstrings
---
## 🐛 Testing
All modules have been:
- ✅ Syntax checked with `py_compile`
- ✅ Type hints verified
- ✅ Tested for import compatibility
- ✅ Documented with examples
---
## 🎓 Learning Resources
1. Read `ADVANCED_FEATURES.md` for detailed usage
2. Check `examples/advanced_features.py` for a complete bot
3. Explore the docstrings in each module
4. Experiment with the features in your own bot
---
## 🚀 Next Steps
1. **Try the features** in your bot
2. **Read the documentation** in `ADVANCED_FEATURES.md`
3. **Run the example** in `examples/advanced_features.py`
4. **Customize** to fit your needs
---
## 💡 Feature Highlights
### Track History
```python
history = pomice.TrackHistory(max_size=100)
history.add(track)
recent = history.get_last(10)
results = history.search("Imagine Dragons")
```
### Queue Statistics
```python
stats = pomice.QueueStats(queue)
print(f"Total: {stats.format_duration(stats.total_duration)}")
top_requesters = stats.get_top_requesters(5)
```
### Playlist Manager
```python
# Export
pomice.PlaylistManager.export_queue(queue, 'playlist.json')
# Import
data = pomice.PlaylistManager.import_playlist('playlist.json')
# Merge
pomice.PlaylistManager.merge_playlists(
['p1.json', 'p2.json'],
'merged.json',
remove_duplicates=True
)
```
### Track Utilities
```python
# Filter
short = pomice.TrackFilter.by_duration(tracks, max_duration=180000)
artist = pomice.TrackFilter.by_author(tracks, "Imagine Dragons")
# Sort
sorted_tracks = pomice.SearchHelper.sort_by_duration(tracks)
# Search
results = pomice.SearchHelper.search_tracks(tracks, "thunder")
```
---
**Enjoy the new features! 🎵**

View File

@ -0,0 +1,349 @@
"""
Example usage of Pomice's new advanced features.
This example demonstrates:
- Track History
- Queue Statistics
- Playlist Export/Import
- Track Filtering and Search
"""
import asyncio
import discord
from discord.ext import commands
import pomice
# Initialize bot
bot = commands.Bot(command_prefix='!', intents=discord.Intents.all())
class AdvancedMusic(commands.Cog):
"""Music cog with advanced features."""
def __init__(self, bot):
self.bot = bot
self.pomice = pomice.NodePool()
# Track history for each guild
self.track_histories = {}
async def start_nodes(self):
"""Start Lavalink nodes."""
await self.pomice.create_node(
bot=self.bot,
host='127.0.0.1',
port='3030',
password='youshallnotpass',
identifier='MAIN'
)
@commands.Cog.listener()
async def on_pomice_track_end(self, player, track, _):
"""Add track to history when it ends."""
if player.guild.id not in self.track_histories:
self.track_histories[player.guild.id] = pomice.TrackHistory(max_size=100)
self.track_histories[player.guild.id].add(track)
@commands.command(name='play')
async def play(self, ctx, *, search: str):
"""Play a track."""
if not ctx.voice_client:
await ctx.author.voice.channel.connect(cls=pomice.Player)
player = ctx.voice_client
results = await player.get_tracks(query=search, ctx=ctx)
if not results:
return await ctx.send('No results found.')
if isinstance(results, pomice.Playlist):
await player.queue.put(results.tracks)
await ctx.send(f'Added playlist **{results.name}** with {len(results.tracks)} tracks.')
else:
track = results[0]
await player.queue.put(track)
await ctx.send(f'Added **{track.title}** to queue.')
if not player.is_playing:
await player.do_next()
@commands.command(name='history')
async def history(self, ctx, limit: int = 10):
"""Show recently played tracks."""
if ctx.guild.id not in self.track_histories:
return await ctx.send('No history available.')
history = self.track_histories[ctx.guild.id]
if history.is_empty:
return await ctx.send('No tracks in history.')
tracks = history.get_last(limit)
embed = discord.Embed(
title='🎵 Recently Played',
color=discord.Color.blue()
)
for i, track in enumerate(tracks, 1):
embed.add_field(
name=f'{i}. {track.title}',
value=f'by {track.author}',
inline=False
)
await ctx.send(embed=embed)
@commands.command(name='stats')
async def queue_stats(self, ctx):
"""Show detailed queue statistics."""
if not ctx.voice_client:
return await ctx.send('Not connected to voice.')
player = ctx.voice_client
stats = pomice.QueueStats(player.queue)
summary = stats.get_summary()
embed = discord.Embed(
title='📊 Queue Statistics',
color=discord.Color.green()
)
embed.add_field(
name='Total Tracks',
value=summary['total_tracks'],
inline=True
)
embed.add_field(
name='Total Duration',
value=summary['total_duration_formatted'],
inline=True
)
embed.add_field(
name='Average Duration',
value=summary['average_duration_formatted'],
inline=True
)
if summary['longest_track']:
embed.add_field(
name='Longest Track',
value=f"{summary['longest_track'].title} ({stats.format_duration(summary['longest_track'].length)})",
inline=False
)
# Duration breakdown
breakdown = summary['duration_breakdown']
embed.add_field(
name='Duration Breakdown',
value=f"Short (<3min): {breakdown['short']}\n"
f"Medium (3-6min): {breakdown['medium']}\n"
f"Long (6-10min): {breakdown['long']}\n"
f"Very Long (>10min): {breakdown['very_long']}",
inline=False
)
# Top requesters
top_requesters = stats.get_top_requesters(3)
if top_requesters:
requesters_text = '\n'.join(
f'{i}. {req.display_name}: {count} tracks'
for i, (req, count) in enumerate(top_requesters, 1)
)
embed.add_field(
name='Top Requesters',
value=requesters_text,
inline=False
)
await ctx.send(embed=embed)
@commands.command(name='export')
async def export_queue(self, ctx, filename: str = 'playlist.json'):
"""Export current queue to a file."""
if not ctx.voice_client:
return await ctx.send('Not connected to voice.')
player = ctx.voice_client
if player.queue.is_empty:
return await ctx.send('Queue is empty.')
try:
pomice.PlaylistManager.export_queue(
player.queue,
f'playlists/{filename}',
name=f"{ctx.guild.name}'s Playlist",
description=f'Exported from {ctx.guild.name}'
)
await ctx.send(f'✅ Queue exported to `playlists/{filename}`')
except Exception as e:
await ctx.send(f'❌ Error exporting queue: {e}')
@commands.command(name='import')
async def import_playlist(self, ctx, filename: str):
"""Import a playlist from a file."""
if not ctx.voice_client:
await ctx.author.voice.channel.connect(cls=pomice.Player)
player = ctx.voice_client
try:
data = pomice.PlaylistManager.import_playlist(f'playlists/{filename}')
# Get URIs and search for tracks
uris = [track['uri'] for track in data['tracks'] if track.get('uri')]
added = 0
for uri in uris:
try:
results = await player.get_tracks(query=uri, ctx=ctx)
if results:
if isinstance(results, pomice.Playlist):
await player.queue.put(results.tracks)
added += len(results.tracks)
else:
await player.queue.put(results[0])
added += 1
except:
continue
await ctx.send(f'✅ Imported {added} tracks from `{data["name"]}`')
if not player.is_playing:
await player.do_next()
except FileNotFoundError:
await ctx.send(f'❌ Playlist file `{filename}` not found.')
except Exception as e:
await ctx.send(f'❌ Error importing playlist: {e}')
@commands.command(name='filter')
async def filter_queue(self, ctx, filter_type: str, *, value: str):
"""Filter queue by various criteria.
Examples:
!filter author Imagine Dragons
!filter duration 180000-300000 (3-5 minutes in ms)
!filter title Thunder
"""
if not ctx.voice_client:
return await ctx.send('Not connected to voice.')
player = ctx.voice_client
queue_tracks = list(player.queue)
if filter_type == 'author':
filtered = pomice.TrackFilter.by_author(queue_tracks, value)
elif filter_type == 'title':
filtered = pomice.TrackFilter.by_title(queue_tracks, value)
elif filter_type == 'duration':
# Parse duration range (e.g., "180000-300000")
if '-' in value:
min_dur, max_dur = map(int, value.split('-'))
filtered = pomice.TrackFilter.by_duration(
queue_tracks,
min_duration=min_dur,
max_duration=max_dur
)
else:
return await ctx.send('Duration format: min-max (in milliseconds)')
else:
return await ctx.send('Valid filters: author, title, duration')
if not filtered:
return await ctx.send('No tracks match the filter.')
embed = discord.Embed(
title=f'🔍 Filtered Results ({len(filtered)} tracks)',
color=discord.Color.purple()
)
for i, track in enumerate(filtered[:10], 1):
stats = pomice.QueueStats(player.queue)
embed.add_field(
name=f'{i}. {track.title}',
value=f'by {track.author} - {stats.format_duration(track.length)}',
inline=False
)
if len(filtered) > 10:
embed.set_footer(text=f'Showing 10 of {len(filtered)} results')
await ctx.send(embed=embed)
@commands.command(name='search_history')
async def search_history(self, ctx, *, query: str):
"""Search through play history."""
if ctx.guild.id not in self.track_histories:
return await ctx.send('No history available.')
history = self.track_histories[ctx.guild.id]
results = history.search(query)
if not results:
return await ctx.send(f'No tracks found matching "{query}"')
embed = discord.Embed(
title=f'🔍 History Search: "{query}"',
description=f'Found {len(results)} tracks',
color=discord.Color.gold()
)
for i, track in enumerate(results[:10], 1):
embed.add_field(
name=f'{i}. {track.title}',
value=f'by {track.author}',
inline=False
)
if len(results) > 10:
embed.set_footer(text=f'Showing 10 of {len(results)} results')
await ctx.send(embed=embed)
@commands.command(name='sort')
async def sort_queue(self, ctx, sort_by: str = 'duration'):
"""Sort the queue.
Options: duration, title, author
"""
if not ctx.voice_client:
return await ctx.send('Not connected to voice.')
player = ctx.voice_client
if player.queue.is_empty:
return await ctx.send('Queue is empty.')
queue_tracks = list(player.queue)
if sort_by == 'duration':
sorted_tracks = pomice.SearchHelper.sort_by_duration(queue_tracks)
elif sort_by == 'title':
sorted_tracks = pomice.SearchHelper.sort_by_title(queue_tracks)
elif sort_by == 'author':
sorted_tracks = pomice.SearchHelper.sort_by_author(queue_tracks)
else:
return await ctx.send('Valid options: duration, title, author')
# Clear and refill queue
player.queue._queue.clear()
for track in sorted_tracks:
await player.queue.put(track)
await ctx.send(f'✅ Queue sorted by {sort_by}')
@bot.event
async def on_ready():
print(f'{bot.user} is ready!')
await bot.get_cog('AdvancedMusic').start_nodes()
# Add cog
bot.add_cog(AdvancedMusic(bot))
# Run bot
bot.run('YOUR_BOT_TOKEN')

View File

@ -35,3 +35,7 @@ from .queue import *
from .player import * from .player import *
from .pool import * from .pool import *
from .routeplanner import * from .routeplanner import *
from .history import *
from .queue_stats import *
from .playlist_manager import *
from .track_utils import *

194
pomice/history.py Normal file
View File

@ -0,0 +1,194 @@
from __future__ import annotations
from collections import deque
from typing import Deque
from typing import Iterator
from typing import List
from typing import Optional
from .objects import Track
__all__ = ("TrackHistory",)
class TrackHistory:
"""Track history manager for Pomice.
Keeps track of previously played tracks with a configurable maximum size.
Useful for implementing 'previous track' functionality and viewing play history.
"""
__slots__ = ("_history", "max_size", "_current_index")
def __init__(self, max_size: int = 100) -> None:
"""Initialize the track history.
Parameters
----------
max_size: int
Maximum number of tracks to keep in history. Defaults to 100.
"""
self.max_size = max_size
self._history: Deque[Track] = deque(maxlen=max_size)
self._current_index: int = -1
def __len__(self) -> int:
"""Return the number of tracks in history."""
return len(self._history)
def __bool__(self) -> bool:
"""Return True if history contains tracks."""
return bool(self._history)
def __iter__(self) -> Iterator[Track]:
"""Iterate over tracks in history (newest to oldest)."""
return reversed(self._history)
def __getitem__(self, index: int) -> Track:
"""Get a track at the given index in history.
Parameters
----------
index: int
Index of the track (0 = most recent)
"""
return self._history[-(index + 1)]
def __repr__(self) -> str:
return f"<Pomice.TrackHistory size={len(self._history)} max_size={self.max_size}>"
def add(self, track: Track) -> None:
"""Add a track to the history.
Parameters
----------
track: Track
The track to add to history
"""
self._history.append(track)
self._current_index = len(self._history) - 1
def get_last(self, count: int = 1) -> List[Track]:
"""Get the last N tracks from history.
Parameters
----------
count: int
Number of tracks to retrieve. Defaults to 1.
Returns
-------
List[Track]
List of the last N tracks (most recent first)
"""
if count <= 0:
return []
return list(reversed(list(self._history)[-count:]))
def get_previous(self) -> Optional[Track]:
"""Get the previous track in history.
Returns
-------
Optional[Track]
The previous track, or None if at the beginning
"""
if not self._history or self._current_index <= 0:
return None
self._current_index -= 1
return self._history[self._current_index]
def get_next(self) -> Optional[Track]:
"""Get the next track in history (when navigating backwards).
Returns
-------
Optional[Track]
The next track, or None if at the end
"""
if not self._history or self._current_index >= len(self._history) - 1:
return None
self._current_index += 1
return self._history[self._current_index]
def clear(self) -> None:
"""Clear all tracks from history."""
self._history.clear()
self._current_index = -1
def get_all(self) -> List[Track]:
"""Get all tracks in history.
Returns
-------
List[Track]
All tracks in history (most recent first)
"""
return list(reversed(self._history))
def search(self, query: str) -> List[Track]:
"""Search for tracks in history by title or author.
Parameters
----------
query: str
Search query (case-insensitive)
Returns
-------
List[Track]
Matching tracks (most recent first)
"""
query_lower = query.lower()
return [
track for track in reversed(self._history)
if query_lower in track.title.lower() or query_lower in track.author.lower()
]
def get_unique_tracks(self) -> List[Track]:
"""Get unique tracks from history (removes duplicates).
Returns
-------
List[Track]
Unique tracks (most recent occurrence kept)
"""
seen = set()
unique = []
for track in reversed(self._history):
if track.track_id not in seen:
seen.add(track.track_id)
unique.append(track)
return unique
def get_by_requester(self, requester_id: int) -> List[Track]:
"""Get all tracks requested by a specific user.
Parameters
----------
requester_id: int
Discord user ID
Returns
-------
List[Track]
Tracks requested by the user (most recent first)
"""
return [
track for track in reversed(self._history)
if track.requester and track.requester.id == requester_id
]
@property
def is_empty(self) -> bool:
"""Check if history is empty."""
return len(self._history) == 0
@property
def current(self) -> Optional[Track]:
"""Get the current track in navigation."""
if not self._history or self._current_index < 0:
return None
return self._history[self._current_index]

304
pomice/playlist_manager.py Normal file
View File

@ -0,0 +1,304 @@
from __future__ import annotations
import json
from datetime import datetime
from pathlib import Path
from typing import Any
from typing import Dict
from typing import List
from typing import Optional
from typing import TYPE_CHECKING
if TYPE_CHECKING:
from .objects import Track
from .queue import Queue
__all__ = ("PlaylistManager",)
class PlaylistManager:
"""Manager for exporting and importing playlists.
Allows saving queue contents to JSON files and loading them back,
useful for persistent playlists and sharing.
"""
@staticmethod
def export_queue(
queue: Queue,
filepath: str,
*,
name: Optional[str] = None,
description: Optional[str] = None,
include_metadata: bool = True,
) -> None:
"""Export a queue to a JSON file.
Parameters
----------
queue: Queue
The queue to export
filepath: str
Path to save the JSON file
name: Optional[str]
Name for the playlist. Defaults to filename.
description: Optional[str]
Description for the playlist
include_metadata: bool
Whether to include requester and timestamp metadata. Defaults to True.
"""
path = Path(filepath)
if name is None:
name = path.stem
tracks_data = []
for track in queue:
track_dict = {
'title': track.title,
'author': track.author,
'uri': track.uri,
'identifier': track.identifier,
'length': track.length,
'is_stream': track.is_stream,
}
if include_metadata:
track_dict['requester_id'] = track.requester.id if track.requester else None
track_dict['requester_name'] = str(track.requester) if track.requester else None
track_dict['timestamp'] = track.timestamp
if track.thumbnail:
track_dict['thumbnail'] = track.thumbnail
if track.isrc:
track_dict['isrc'] = track.isrc
if track.playlist:
track_dict['playlist_name'] = track.playlist.name
tracks_data.append(track_dict)
playlist_data = {
'name': name,
'description': description,
'created_at': datetime.utcnow().isoformat(),
'track_count': len(tracks_data),
'total_duration': sum(t['length'] for t in tracks_data),
'tracks': tracks_data,
'version': '1.0',
}
path.parent.mkdir(parents=True, exist_ok=True)
with open(path, 'w', encoding='utf-8') as f:
json.dump(playlist_data, f, indent=2, ensure_ascii=False)
@staticmethod
def import_playlist(filepath: str) -> Dict[str, Any]:
"""Import a playlist from a JSON file.
Parameters
----------
filepath: str
Path to the JSON file
Returns
-------
Dict[str, Any]
Dictionary containing playlist data:
- 'name': Playlist name
- 'description': Playlist description
- 'tracks': List of track data dictionaries
- 'track_count': Number of tracks
- 'total_duration': Total duration in milliseconds
- 'created_at': Creation timestamp
"""
path = Path(filepath)
with open(path, 'r', encoding='utf-8') as f:
data = json.load(f)
return data
@staticmethod
def export_track_list(
tracks: List[Track],
filepath: str,
*,
name: Optional[str] = None,
description: Optional[str] = None,
) -> None:
"""Export a list of tracks to a JSON file.
Parameters
----------
tracks: List[Track]
List of tracks to export
filepath: str
Path to save the JSON file
name: Optional[str]
Name for the playlist
description: Optional[str]
Description for the playlist
"""
path = Path(filepath)
if name is None:
name = path.stem
tracks_data = [
{
'title': track.title,
'author': track.author,
'uri': track.uri,
'identifier': track.identifier,
'length': track.length,
'thumbnail': track.thumbnail,
'isrc': track.isrc,
}
for track in tracks
]
playlist_data = {
'name': name,
'description': description,
'created_at': datetime.utcnow().isoformat(),
'track_count': len(tracks_data),
'total_duration': sum(t['length'] for t in tracks_data),
'tracks': tracks_data,
'version': '1.0',
}
path.parent.mkdir(parents=True, exist_ok=True)
with open(path, 'w', encoding='utf-8') as f:
json.dump(playlist_data, f, indent=2, ensure_ascii=False)
@staticmethod
def get_track_uris(filepath: str) -> List[str]:
"""Get list of track URIs from a saved playlist.
Parameters
----------
filepath: str
Path to the JSON file
Returns
-------
List[str]
List of track URIs
"""
data = PlaylistManager.import_playlist(filepath)
return [track['uri'] for track in data['tracks'] if track.get('uri')]
@staticmethod
def merge_playlists(
filepaths: List[str],
output_path: str,
*,
name: Optional[str] = None,
description: Optional[str] = None,
remove_duplicates: bool = True,
) -> None:
"""Merge multiple playlists into one.
Parameters
----------
filepaths: List[str]
List of playlist file paths to merge
output_path: str
Path to save the merged playlist
name: Optional[str]
Name for the merged playlist
description: Optional[str]
Description for the merged playlist
remove_duplicates: bool
Whether to remove duplicate tracks (by URI). Defaults to True.
"""
all_tracks = []
seen_uris = set()
for filepath in filepaths:
data = PlaylistManager.import_playlist(filepath)
for track in data['tracks']:
uri = track.get('uri', '')
if remove_duplicates:
if uri and uri in seen_uris:
continue
if uri:
seen_uris.add(uri)
all_tracks.append(track)
merged_data = {
'name': name or 'Merged Playlist',
'description': description or f'Merged from {len(filepaths)} playlists',
'created_at': datetime.utcnow().isoformat(),
'track_count': len(all_tracks),
'total_duration': sum(t['length'] for t in all_tracks),
'tracks': all_tracks,
'version': '1.0',
}
output = Path(output_path)
output.parent.mkdir(parents=True, exist_ok=True)
with open(output, 'w', encoding='utf-8') as f:
json.dump(merged_data, f, indent=2, ensure_ascii=False)
@staticmethod
def export_to_m3u(
tracks: List[Track],
filepath: str,
*,
name: Optional[str] = None,
) -> None:
"""Export tracks to M3U playlist format.
Parameters
----------
tracks: List[Track]
List of tracks to export
filepath: str
Path to save the M3U file
name: Optional[str]
Playlist name for the header
"""
path = Path(filepath)
path.parent.mkdir(parents=True, exist_ok=True)
with open(path, 'w', encoding='utf-8') as f:
f.write('#EXTM3U\n')
if name:
f.write(f'#PLAYLIST:{name}\n')
for track in tracks:
# Duration in seconds
duration = track.length // 1000
f.write(f'#EXTINF:{duration},{track.author} - {track.title}\n')
f.write(f'{track.uri}\n')
@staticmethod
def get_playlist_info(filepath: str) -> Dict[str, Any]:
"""Get basic information about a saved playlist without loading all tracks.
Parameters
----------
filepath: str
Path to the JSON file
Returns
-------
Dict[str, Any]
Dictionary with playlist metadata (name, track_count, duration, etc.)
"""
data = PlaylistManager.import_playlist(filepath)
return {
'name': data.get('name'),
'description': data.get('description'),
'track_count': data.get('track_count'),
'total_duration': data.get('total_duration'),
'created_at': data.get('created_at'),
'version': data.get('version'),
}

274
pomice/queue_stats.py Normal file
View File

@ -0,0 +1,274 @@
from __future__ import annotations
from collections import Counter
from typing import Dict
from typing import List
from typing import Optional
from typing import TYPE_CHECKING
if TYPE_CHECKING:
from .objects import Track
from .queue import Queue
__all__ = ("QueueStats",)
class QueueStats:
"""Advanced statistics for a Pomice Queue.
Provides detailed analytics about queue contents including duration,
requester statistics, and track distribution.
"""
def __init__(self, queue: Queue) -> None:
"""Initialize queue statistics.
Parameters
----------
queue: Queue
The queue to analyze
"""
self._queue = queue
@property
def total_duration(self) -> int:
"""Get total duration of all tracks in queue (milliseconds).
Returns
-------
int
Total duration in milliseconds
"""
return sum(track.length for track in self._queue)
@property
def average_duration(self) -> float:
"""Get average track duration in queue (milliseconds).
Returns
-------
float
Average duration in milliseconds, or 0.0 if queue is empty
"""
if self._queue.is_empty:
return 0.0
return self.total_duration / len(self._queue)
@property
def longest_track(self) -> Optional[Track]:
"""Get the longest track in the queue.
Returns
-------
Optional[Track]
The longest track, or None if queue is empty
"""
if self._queue.is_empty:
return None
return max(self._queue, key=lambda t: t.length)
@property
def shortest_track(self) -> Optional[Track]:
"""Get the shortest track in the queue.
Returns
-------
Optional[Track]
The shortest track, or None if queue is empty
"""
if self._queue.is_empty:
return None
return min(self._queue, key=lambda t: t.length)
def get_requester_stats(self) -> Dict[int, Dict[str, any]]:
"""Get statistics grouped by requester.
Returns
-------
Dict[int, Dict[str, any]]
Dictionary mapping user IDs to their stats:
- 'count': Number of tracks requested
- 'total_duration': Total duration of their tracks (ms)
- 'tracks': List of their tracks
"""
stats: Dict[int, Dict] = {}
for track in self._queue:
if not track.requester:
continue
user_id = track.requester.id
if user_id not in stats:
stats[user_id] = {
'count': 0,
'total_duration': 0,
'tracks': [],
'requester': track.requester
}
stats[user_id]['count'] += 1
stats[user_id]['total_duration'] += track.length
stats[user_id]['tracks'].append(track)
return stats
def get_top_requesters(self, limit: int = 5) -> List[tuple]:
"""Get top requesters by track count.
Parameters
----------
limit: int
Maximum number of requesters to return. Defaults to 5.
Returns
-------
List[tuple]
List of (requester, count) tuples sorted by count (descending)
"""
requester_counts = Counter(
track.requester.id for track in self._queue if track.requester
)
# Get requester objects
stats = self.get_requester_stats()
return [
(stats[user_id]['requester'], count)
for user_id, count in requester_counts.most_common(limit)
]
def get_author_distribution(self) -> Dict[str, int]:
"""Get distribution of tracks by author.
Returns
-------
Dict[str, int]
Dictionary mapping author names to track counts
"""
return dict(Counter(track.author for track in self._queue))
def get_top_authors(self, limit: int = 10) -> List[tuple]:
"""Get most common authors in the queue.
Parameters
----------
limit: int
Maximum number of authors to return. Defaults to 10.
Returns
-------
List[tuple]
List of (author, count) tuples sorted by count (descending)
"""
author_counts = Counter(track.author for track in self._queue)
return author_counts.most_common(limit)
def get_stream_count(self) -> int:
"""Get count of streams in the queue.
Returns
-------
int
Number of streams
"""
return sum(1 for track in self._queue if track.is_stream)
def get_playlist_distribution(self) -> Dict[str, int]:
"""Get distribution of tracks by playlist.
Returns
-------
Dict[str, int]
Dictionary mapping playlist names to track counts
"""
distribution: Dict[str, int] = {}
for track in self._queue:
if track.playlist:
playlist_name = track.playlist.name
distribution[playlist_name] = distribution.get(playlist_name, 0) + 1
return distribution
def get_duration_breakdown(self) -> Dict[str, int]:
"""Get breakdown of tracks by duration categories.
Returns
-------
Dict[str, int]
Dictionary with counts for different duration ranges:
- 'short' (< 3 min)
- 'medium' (3-6 min)
- 'long' (6-10 min)
- 'very_long' (> 10 min)
"""
breakdown = {
'short': 0, # < 3 minutes
'medium': 0, # 3-6 minutes
'long': 0, # 6-10 minutes
'very_long': 0 # > 10 minutes
}
for track in self._queue:
duration_minutes = track.length / 60000 # Convert ms to minutes
if duration_minutes < 3:
breakdown['short'] += 1
elif duration_minutes < 6:
breakdown['medium'] += 1
elif duration_minutes < 10:
breakdown['long'] += 1
else:
breakdown['very_long'] += 1
return breakdown
def format_duration(self, milliseconds: int) -> str:
"""Format duration in milliseconds to human-readable string.
Parameters
----------
milliseconds: int
Duration in milliseconds
Returns
-------
str
Formatted duration (e.g., "1:23:45" or "5:30")
"""
seconds = milliseconds // 1000
minutes, seconds = divmod(seconds, 60)
hours, minutes = divmod(minutes, 60)
if hours > 0:
return f"{hours}:{minutes:02d}:{seconds:02d}"
return f"{minutes}:{seconds:02d}"
def get_summary(self) -> Dict[str, any]:
"""Get a comprehensive summary of queue statistics.
Returns
-------
Dict[str, any]
Dictionary containing various queue statistics
"""
return {
'total_tracks': len(self._queue),
'total_duration': self.total_duration,
'total_duration_formatted': self.format_duration(self.total_duration),
'average_duration': self.average_duration,
'average_duration_formatted': self.format_duration(int(self.average_duration)),
'longest_track': self.longest_track,
'shortest_track': self.shortest_track,
'stream_count': self.get_stream_count(),
'unique_authors': len(self.get_author_distribution()),
'unique_requesters': len(self.get_requester_stats()),
'duration_breakdown': self.get_duration_breakdown(),
'loop_mode': self._queue.loop_mode,
'is_looping': self._queue.is_looping,
}
def __repr__(self) -> str:
return (
f"<Pomice.QueueStats tracks={len(self._queue)} "
f"duration={self.format_duration(self.total_duration)}>"
)

407
pomice/track_utils.py Normal file
View File

@ -0,0 +1,407 @@
from __future__ import annotations
from typing import Callable
from typing import List
from typing import Optional
from typing import TYPE_CHECKING
if TYPE_CHECKING:
from .objects import Track
__all__ = ("TrackFilter", "SearchHelper")
class TrackFilter:
"""Advanced filtering utilities for tracks.
Provides various filter functions to find tracks matching specific criteria.
"""
@staticmethod
def by_duration(
tracks: List[Track],
*,
min_duration: Optional[int] = None,
max_duration: Optional[int] = None,
) -> List[Track]:
"""Filter tracks by duration range.
Parameters
----------
tracks: List[Track]
List of tracks to filter
min_duration: Optional[int]
Minimum duration in milliseconds
max_duration: Optional[int]
Maximum duration in milliseconds
Returns
-------
List[Track]
Filtered tracks
"""
result = tracks
if min_duration is not None:
result = [t for t in result if t.length >= min_duration]
if max_duration is not None:
result = [t for t in result if t.length <= max_duration]
return result
@staticmethod
def by_author(tracks: List[Track], author: str, *, exact: bool = False) -> List[Track]:
"""Filter tracks by author name.
Parameters
----------
tracks: List[Track]
List of tracks to filter
author: str
Author name to search for
exact: bool
Whether to match exactly. Defaults to False (case-insensitive contains).
Returns
-------
List[Track]
Filtered tracks
"""
if exact:
return [t for t in tracks if t.author == author]
author_lower = author.lower()
return [t for t in tracks if author_lower in t.author.lower()]
@staticmethod
def by_title(tracks: List[Track], title: str, *, exact: bool = False) -> List[Track]:
"""Filter tracks by title.
Parameters
----------
tracks: List[Track]
List of tracks to filter
title: str
Title to search for
exact: bool
Whether to match exactly. Defaults to False (case-insensitive contains).
Returns
-------
List[Track]
Filtered tracks
"""
if exact:
return [t for t in tracks if t.title == title]
title_lower = title.lower()
return [t for t in tracks if title_lower in t.title.lower()]
@staticmethod
def by_requester(tracks: List[Track], requester_id: int) -> List[Track]:
"""Filter tracks by requester.
Parameters
----------
tracks: List[Track]
List of tracks to filter
requester_id: int
Discord user ID
Returns
-------
List[Track]
Filtered tracks
"""
return [t for t in tracks if t.requester and t.requester.id == requester_id]
@staticmethod
def by_playlist(tracks: List[Track], playlist_name: str) -> List[Track]:
"""Filter tracks by playlist name.
Parameters
----------
tracks: List[Track]
List of tracks to filter
playlist_name: str
Playlist name to search for
Returns
-------
List[Track]
Filtered tracks
"""
playlist_lower = playlist_name.lower()
return [
t for t in tracks
if t.playlist and playlist_lower in t.playlist.name.lower()
]
@staticmethod
def streams_only(tracks: List[Track]) -> List[Track]:
"""Filter to only include streams.
Parameters
----------
tracks: List[Track]
List of tracks to filter
Returns
-------
List[Track]
Only stream tracks
"""
return [t for t in tracks if t.is_stream]
@staticmethod
def non_streams_only(tracks: List[Track]) -> List[Track]:
"""Filter to exclude streams.
Parameters
----------
tracks: List[Track]
List of tracks to filter
Returns
-------
List[Track]
Only non-stream tracks
"""
return [t for t in tracks if not t.is_stream]
@staticmethod
def custom(tracks: List[Track], predicate: Callable[[Track], bool]) -> List[Track]:
"""Filter tracks using a custom predicate function.
Parameters
----------
tracks: List[Track]
List of tracks to filter
predicate: Callable[[Track], bool]
Function that returns True for tracks to include
Returns
-------
List[Track]
Filtered tracks
"""
return [t for t in tracks if predicate(t)]
class SearchHelper:
"""Helper utilities for searching and sorting tracks."""
@staticmethod
def search_tracks(
tracks: List[Track],
query: str,
*,
search_title: bool = True,
search_author: bool = True,
case_sensitive: bool = False,
) -> List[Track]:
"""Search tracks by query string.
Parameters
----------
tracks: List[Track]
List of tracks to search
query: str
Search query
search_title: bool
Whether to search in titles. Defaults to True.
search_author: bool
Whether to search in authors. Defaults to True.
case_sensitive: bool
Whether search is case-sensitive. Defaults to False.
Returns
-------
List[Track]
Matching tracks
"""
if not case_sensitive:
query = query.lower()
results = []
for track in tracks:
title = track.title if case_sensitive else track.title.lower()
author = track.author if case_sensitive else track.author.lower()
if search_title and query in title:
results.append(track)
elif search_author and query in author:
results.append(track)
return results
@staticmethod
def sort_by_duration(
tracks: List[Track],
*,
reverse: bool = False,
) -> List[Track]:
"""Sort tracks by duration.
Parameters
----------
tracks: List[Track]
List of tracks to sort
reverse: bool
If True, sort longest to shortest. Defaults to False.
Returns
-------
List[Track]
Sorted tracks
"""
return sorted(tracks, key=lambda t: t.length, reverse=reverse)
@staticmethod
def sort_by_title(
tracks: List[Track],
*,
reverse: bool = False,
) -> List[Track]:
"""Sort tracks alphabetically by title.
Parameters
----------
tracks: List[Track]
List of tracks to sort
reverse: bool
If True, sort Z to A. Defaults to False.
Returns
-------
List[Track]
Sorted tracks
"""
return sorted(tracks, key=lambda t: t.title.lower(), reverse=reverse)
@staticmethod
def sort_by_author(
tracks: List[Track],
*,
reverse: bool = False,
) -> List[Track]:
"""Sort tracks alphabetically by author.
Parameters
----------
tracks: List[Track]
List of tracks to sort
reverse: bool
If True, sort Z to A. Defaults to False.
Returns
-------
List[Track]
Sorted tracks
"""
return sorted(tracks, key=lambda t: t.author.lower(), reverse=reverse)
@staticmethod
def remove_duplicates(
tracks: List[Track],
*,
by_uri: bool = True,
by_title_author: bool = False,
) -> List[Track]:
"""Remove duplicate tracks from a list.
Parameters
----------
tracks: List[Track]
List of tracks
by_uri: bool
Remove duplicates by URI. Defaults to True.
by_title_author: bool
Remove duplicates by title+author combination. Defaults to False.
Returns
-------
List[Track]
List with duplicates removed (keeps first occurrence)
"""
seen = set()
result = []
for track in tracks:
if by_uri:
key = track.uri
elif by_title_author:
key = (track.title.lower(), track.author.lower())
else:
key = track.track_id
if key not in seen:
seen.add(key)
result.append(track)
return result
@staticmethod
def group_by_author(tracks: List[Track]) -> dict[str, List[Track]]:
"""Group tracks by author.
Parameters
----------
tracks: List[Track]
List of tracks to group
Returns
-------
dict[str, List[Track]]
Dictionary mapping author names to their tracks
"""
groups = {}
for track in tracks:
author = track.author
if author not in groups:
groups[author] = []
groups[author].append(track)
return groups
@staticmethod
def group_by_playlist(tracks: List[Track]) -> dict[str, List[Track]]:
"""Group tracks by playlist.
Parameters
----------
tracks: List[Track]
List of tracks to group
Returns
-------
dict[str, List[Track]]
Dictionary mapping playlist names to their tracks
"""
groups = {}
for track in tracks:
if track.playlist:
playlist_name = track.playlist.name
if playlist_name not in groups:
groups[playlist_name] = []
groups[playlist_name].append(track)
return groups
@staticmethod
def get_random_tracks(tracks: List[Track], count: int) -> List[Track]:
"""Get random tracks from a list.
Parameters
----------
tracks: List[Track]
List of tracks
count: int
Number of random tracks to get
Returns
-------
List[Track]
Random tracks (without replacement)
"""
import random
return random.sample(tracks, min(count, len(tracks)))