Add popularity statistics tab with artist and album rankings

- New Popularity Stats tab showing top artists and albums by upload count
- Interactive charts with truncated names and hover tooltips
- Smart album name cleaning to remove redundant artist prefixes
- Library format detection and user-friendly error messages
- Configurable top entries display (5-50, default 10)
- Read-only tables to prevent accidental data modification
- Centered charts with proper label spacing
- Removed mplcursors dependency for better compatibility
- Updated README with new features and screenshot
This commit is contained in:
Alec
2025-07-26 11:26:55 +02:00
parent e66d578a86
commit cf197456de
3 changed files with 498 additions and 99 deletions
BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 711 KiB

+7 -2
View File
@@ -12,6 +12,8 @@ A GUI tool to analyze upload and download statistics from your slskd transfers d
- Lists top users by data transferred - Lists top users by data transferred
- Shows statistics by file type - Shows statistics by file type
- Filter statistics by time period (All time, Last month, Last year) - Filter statistics by time period (All time, Last month, Last year)
- **NEW**: Artist and album popularity statistics based on successful uploads
- Smart album name cleaning (removes redundant artist names from folder names)
- User-friendly graphical interface with summary and detailed tables - User-friendly graphical interface with summary and detailed tables
## Requirements ## Requirements
@@ -20,12 +22,11 @@ A GUI tool to analyze upload and download statistics from your slskd transfers d
- SQLite3 - SQLite3
- PyQt5 - PyQt5
- matplotlib - matplotlib
- mplcursors
## Installation ## Installation
1. Clone or download this repository to your local machine 1. Clone or download this repository to your local machine
2. Install dependencies: `pip3 install PyQt5 matplotlib mplcursors` 2. Install dependencies: `pip3 install PyQt5 matplotlib`
3. Place your `transfers.db` file in the same directory as the script, or use the file browser to select database files 3. Place your `transfers.db` file in the same directory as the script, or use the file browser to select database files
## Usage ## Usage
@@ -42,6 +43,7 @@ With the GUI, you can:
- View upload and download statistics side-by-side - View upload and download statistics side-by-side
- See summary statistics and detailed tables for users and file types - See summary statistics and detailed tables for users and file types
- **NEW**: Visual time series graphs showing transfer trends over time - **NEW**: Visual time series graphs showing transfer trends over time
- **NEW**: Analyze artist and album popularity with interactive charts and tables
## Screenshots ## Screenshots
@@ -51,6 +53,9 @@ With the GUI, you can:
### Visual Stats with Time Series Graphs ### Visual Stats with Time Series Graphs
![Visual Stats](Visual.png) ![Visual Stats](Visual.png)
### Popularity Stats with Artist and Album Rankings
![Popularity Stats](Popularity.png)
## Database Compatibility ## Database Compatibility
This tool automatically detects and works with both: This tool automatically detects and works with both:
+491 -97
View File
@@ -23,7 +23,6 @@ from matplotlib.backends.backend_qt5agg import FigureCanvasQTAgg as FigureCanvas
from matplotlib.figure import Figure from matplotlib.figure import Figure
import matplotlib.dates as mdates import matplotlib.dates as mdates
from matplotlib.ticker import MaxNLocator from matplotlib.ticker import MaxNLocator
import mplcursors
def format_size(size_bytes): def format_size(size_bytes):
"""Format byte size to human readable format""" """Format byte size to human readable format"""
@@ -432,6 +431,9 @@ class MainWindow(QMainWindow):
# Create visual stats tab # Create visual stats tab
self.createVisualTab() self.createVisualTab()
# Create popularity stats tab
self.createPopularityTab()
# Add tabs to main layout # Add tabs to main layout
self.mainLayout.addWidget(self.tabs) self.mainLayout.addWidget(self.tabs)
@@ -476,6 +478,7 @@ class MainWindow(QMainWindow):
self.uploadUsersTable.horizontalHeader().setSectionResizeMode(QHeaderView.Stretch) self.uploadUsersTable.horizontalHeader().setSectionResizeMode(QHeaderView.Stretch)
self.uploadUsersTable.setMinimumHeight(100) self.uploadUsersTable.setMinimumHeight(100)
self.uploadUsersTable.setAlternatingRowColors(True) self.uploadUsersTable.setAlternatingRowColors(True)
self.uploadUsersTable.setEditTriggers(QTableWidget.NoEditTriggers)
uploadUsersLayout.addWidget(self.uploadUsersTable) uploadUsersLayout.addWidget(self.uploadUsersTable)
uploadUsersGroup.setLayout(uploadUsersLayout) uploadUsersGroup.setLayout(uploadUsersLayout)
usersSection.addWidget(uploadUsersGroup) usersSection.addWidget(uploadUsersGroup)
@@ -488,6 +491,7 @@ class MainWindow(QMainWindow):
self.downloadUsersTable.horizontalHeader().setSectionResizeMode(QHeaderView.Stretch) self.downloadUsersTable.horizontalHeader().setSectionResizeMode(QHeaderView.Stretch)
self.downloadUsersTable.setMinimumHeight(100) self.downloadUsersTable.setMinimumHeight(100)
self.downloadUsersTable.setAlternatingRowColors(True) self.downloadUsersTable.setAlternatingRowColors(True)
self.downloadUsersTable.setEditTriggers(QTableWidget.NoEditTriggers)
downloadUsersLayout.addWidget(self.downloadUsersTable) downloadUsersLayout.addWidget(self.downloadUsersTable)
downloadUsersGroup.setLayout(downloadUsersLayout) downloadUsersGroup.setLayout(downloadUsersLayout)
usersSection.addWidget(downloadUsersGroup) usersSection.addWidget(downloadUsersGroup)
@@ -505,6 +509,7 @@ class MainWindow(QMainWindow):
self.uploadTypesTable.horizontalHeader().setSectionResizeMode(QHeaderView.Stretch) self.uploadTypesTable.horizontalHeader().setSectionResizeMode(QHeaderView.Stretch)
self.uploadTypesTable.setMinimumHeight(100) self.uploadTypesTable.setMinimumHeight(100)
self.uploadTypesTable.setAlternatingRowColors(True) self.uploadTypesTable.setAlternatingRowColors(True)
self.uploadTypesTable.setEditTriggers(QTableWidget.NoEditTriggers)
uploadTypesLayout.addWidget(self.uploadTypesTable) uploadTypesLayout.addWidget(self.uploadTypesTable)
uploadTypesGroup.setLayout(uploadTypesLayout) uploadTypesGroup.setLayout(uploadTypesLayout)
typesSection.addWidget(uploadTypesGroup) typesSection.addWidget(uploadTypesGroup)
@@ -517,6 +522,7 @@ class MainWindow(QMainWindow):
self.downloadTypesTable.horizontalHeader().setSectionResizeMode(QHeaderView.Stretch) self.downloadTypesTable.horizontalHeader().setSectionResizeMode(QHeaderView.Stretch)
self.downloadTypesTable.setMinimumHeight(100) self.downloadTypesTable.setMinimumHeight(100)
self.downloadTypesTable.setAlternatingRowColors(True) self.downloadTypesTable.setAlternatingRowColors(True)
self.downloadTypesTable.setEditTriggers(QTableWidget.NoEditTriggers)
downloadTypesLayout.addWidget(self.downloadTypesTable) downloadTypesLayout.addWidget(self.downloadTypesTable)
downloadTypesGroup.setLayout(downloadTypesLayout) downloadTypesGroup.setLayout(downloadTypesLayout)
typesSection.addWidget(downloadTypesGroup) typesSection.addWidget(downloadTypesGroup)
@@ -608,6 +614,81 @@ class MainWindow(QMainWindow):
self.timeSeriesData = None self.timeSeriesData = None
self.updateGraphs() self.updateGraphs()
def createPopularityTab(self):
# Create popularity stats tab
self.popularityWidget = QWidget()
self.popularityLayout = QVBoxLayout(self.popularityWidget)
# Create compact top entries selector with fixed height
settingsWidget = QWidget()
settingsWidget.setMaximumHeight(35) # Fixed height to prevent scaling
topEntriesLayout = QHBoxLayout(settingsWidget)
topEntriesLayout.setContentsMargins(5, 5, 5, 5) # Minimal margins
topEntriesLayout.addWidget(QLabel("Show top:"))
self.topEntriesSpinBox = QSpinBox()
self.topEntriesSpinBox.setMinimum(5)
self.topEntriesSpinBox.setMaximum(50)
self.topEntriesSpinBox.setValue(10)
self.topEntriesSpinBox.valueChanged.connect(self.updatePopularityStats)
topEntriesLayout.addWidget(self.topEntriesSpinBox)
topEntriesLayout.addWidget(QLabel("entries"))
topEntriesLayout.addStretch() # Push everything to the left
self.popularityLayout.addWidget(settingsWidget)
# Create splitter for side-by-side layout
popularitySplitter = QSplitter(Qt.Horizontal)
# Create artists section
artistsGroup = QGroupBox("Top Artists by Uploads")
artistsLayout = QVBoxLayout()
self.artistsTable = QTableWidget()
self.artistsTable.setColumnCount(3)
self.artistsTable.setHorizontalHeaderLabels(["Artist", "Uploads", "Total Data"])
self.artistsTable.horizontalHeader().setStretchLastSection(True)
self.artistsTable.setAlternatingRowColors(True)
self.artistsTable.setSortingEnabled(True)
self.artistsTable.setEditTriggers(QTableWidget.NoEditTriggers)
artistsLayout.addWidget(self.artistsTable)
# Artists chart
self.artistsFigure = Figure(figsize=(8, 6))
self.artistsCanvas = FigureCanvas(self.artistsFigure)
artistsLayout.addWidget(self.artistsCanvas, 0, Qt.AlignCenter)
artistsGroup.setLayout(artistsLayout)
popularitySplitter.addWidget(artistsGroup)
# Create albums section
albumsGroup = QGroupBox("Top Albums by Uploads")
albumsLayout = QVBoxLayout()
self.albumsTable = QTableWidget()
self.albumsTable.setColumnCount(4)
self.albumsTable.setHorizontalHeaderLabels(["Artist", "Album", "Uploads", "Total Data"])
self.albumsTable.horizontalHeader().setStretchLastSection(True)
self.albumsTable.setAlternatingRowColors(True)
self.albumsTable.setSortingEnabled(True)
self.albumsTable.setEditTriggers(QTableWidget.NoEditTriggers)
albumsLayout.addWidget(self.albumsTable)
# Albums chart
self.albumsFigure = Figure(figsize=(8, 6))
self.albumsCanvas = FigureCanvas(self.albumsFigure)
albumsLayout.addWidget(self.albumsCanvas, 0, Qt.AlignCenter)
albumsGroup.setLayout(albumsLayout)
popularitySplitter.addWidget(albumsGroup)
self.popularityLayout.addWidget(popularitySplitter)
# Add the popularity tab
self.tabs.addTab(self.popularityWidget, "Popularity Stats")
def addDatabaseFile(self): def addDatabaseFile(self):
options = QFileDialog.Options() options = QFileDialog.Options()
files, _ = QFileDialog.getOpenFileNames( files, _ = QFileDialog.getOpenFileNames(
@@ -677,10 +758,8 @@ class MainWindow(QMainWindow):
if plot_result: if plot_result:
lines.append((plot_result[0], 'new_users', 'New Users')) lines.append((plot_result[0], 'new_users', 'New Users'))
# Add interactive cursors to amounts graph # Interactive functionality disabled to avoid compatibility issues
if lines: # Charts remain readable with legends and axis labels
cursor = mplcursors.cursor([line[0] for line in lines], hover=True)
cursor.connect('add', lambda sel: self.format_amounts_tooltip(sel, lines))
ax1.set_xlabel('Date') ax1.set_xlabel('Date')
ax1.set_ylabel('Count') ax1.set_ylabel('Count')
@@ -780,10 +859,8 @@ class MainWindow(QMainWindow):
ax2.set_ylabel('Error Rate (%)') ax2.set_ylabel('Error Rate (%)')
ax2.legend() ax2.legend()
# Add interactive cursors to ratios graph # Interactive functionality disabled to avoid compatibility issues
if ratio_lines: # Charts remain readable with legends and axis labels
cursor = mplcursors.cursor([line[0] for line in ratio_lines], hover=True)
cursor.connect('add', lambda sel: self.format_ratios_tooltip(sel, ratio_lines))
if show_speeds or show_error_rates: if show_speeds or show_error_rates:
ax2.set_xlabel('Date') ax2.set_xlabel('Date')
@@ -806,96 +883,12 @@ class MainWindow(QMainWindow):
self.ratiosCanvas.draw() self.ratiosCanvas.draw()
def format_amounts_tooltip(self, sel, lines): def format_amounts_tooltip(self, sel, lines):
"""Format tooltip for amounts graph""" """Format tooltip for amounts graph - disabled"""
try: pass
# Get the index from the selection - mplcursors uses different ways to access index
if hasattr(sel.target, 'index'):
index = int(sel.target.index)
elif hasattr(sel, 'index'):
index = int(sel.index)
else:
# Try to get index from the target coordinates
target_x = sel.target[0]
# Find closest date
dates = [mdates.date2num(d) for d in self.timeSeriesData['dates']]
index = min(range(len(dates)), key=lambda i: abs(dates[i] - target_x))
if index >= len(self.timeSeriesData['dates']):
sel.annotation.set_text('Index out of range')
return
date = self.timeSeriesData['dates'][index]
# Find which line was selected
line_obj = sel.artist
for line, data_key, label in lines:
if line == line_obj:
if data_key == 'total_errors':
# Special case for total errors (calculated)
upload_errors = self.timeSeriesData['upload_errors'][index]
download_errors = self.timeSeriesData['download_errors'][index]
total_errors = upload_errors + download_errors
value = total_errors
else:
value = self.timeSeriesData[data_key][index]
# Format the tooltip
date_str = date.strftime('%Y-%m-%d')
sel.annotation.set_text(f'{label}\nDate: {date_str}\nValue: {value:,}')
return
# If we couldn't find the line, show basic info
sel.annotation.set_text(f'Date: {date.strftime("%Y-%m-%d")}')
except (AttributeError, IndexError, ValueError, TypeError) as e:
# More detailed error info for debugging
sel.annotation.set_text(f'Error: {str(e)[:50]}')
def format_ratios_tooltip(self, sel, lines): def format_ratios_tooltip(self, sel, lines):
"""Format tooltip for ratios graph""" """Format tooltip for ratios graph - disabled"""
try: pass
# Get the index from the selection - mplcursors uses different ways to access index
if hasattr(sel.target, 'index'):
index = int(sel.target.index)
elif hasattr(sel, 'index'):
index = int(sel.index)
else:
# Try to get index from the target coordinates
target_x = sel.target[0]
# Find closest date
dates = [mdates.date2num(d) for d in self.timeSeriesData['dates']]
index = min(range(len(dates)), key=lambda i: abs(dates[i] - target_x))
if index >= len(self.timeSeriesData['dates']):
sel.annotation.set_text('Index out of range')
return
date = self.timeSeriesData['dates'][index]
# Find which line was selected
line_obj = sel.artist
for line, data_key, label, unit in lines:
if line == line_obj:
if 'speeds' in data_key:
# Convert from bytes/s to MB/s for display
value = self.timeSeriesData[data_key][index] / (1024*1024)
value_str = f'{value:.2f} {unit}'
else:
# Error rates
value = self.timeSeriesData[data_key][index]
value_str = f'{value:.2f}{unit}'
# Format the tooltip
date_str = date.strftime('%Y-%m-%d')
sel.annotation.set_text(f'{label}\nDate: {date_str}\nValue: {value_str}')
return
# If we couldn't find the line, show basic info
sel.annotation.set_text(f'Date: {date.strftime("%Y-%m-%d")}')
except (AttributeError, IndexError, ValueError, TypeError) as e:
# More detailed error info for debugging
sel.annotation.set_text(f'Error: {str(e)[:50]}')
def analyzeTransfers(self): def analyzeTransfers(self):
if not self.db_paths: if not self.db_paths:
@@ -989,6 +982,407 @@ class MainWindow(QMainWindow):
# Get time series data and update graphs # Get time series data and update graphs
self.timeSeriesData = get_time_series_data(self.db_paths, days) self.timeSeriesData = get_time_series_data(self.db_paths, days)
self.updateGraphs() self.updateGraphs()
# Update popularity stats
self.updatePopularityStats()
def updatePopularityStats(self):
if not self.db_paths:
return
# Get current period setting
period_text = self.periodComboBox.currentText()
if period_text == "All time":
days = None
elif period_text == "Last month":
days = 30
elif period_text == "Last year":
days = 365
else:
days = None
top_n = self.topEntriesSpinBox.value()
# Analyze library format first
format_info = analyze_library_format(self.db_paths)
# Get popularity data
artist_stats, album_stats = get_popularity_stats(self.db_paths, days)
# Check if we have data and good format compatibility
if not artist_stats and not album_stats:
self.showPopularityError("No successful upload transfers found.", format_info)
return
elif format_info['match_percentage'] < 50:
self.showPopularityWarning(format_info)
# Update artists table and chart
self.updateArtistsTable(artist_stats, top_n)
self.updateArtistsChart(artist_stats, top_n)
# Update albums table and chart
self.updateAlbumsTable(album_stats, top_n)
self.updateAlbumsChart(album_stats, top_n)
def updateArtistsTable(self, artist_stats, top_n):
# Sort by transfer count
sorted_artists = sorted(artist_stats.items(), key=lambda x: x[1]['count'], reverse=True)[:top_n]
self.artistsTable.setRowCount(len(sorted_artists))
for i, (artist, stats) in enumerate(sorted_artists):
self.artistsTable.setItem(i, 0, QTableWidgetItem(artist))
self.artistsTable.setItem(i, 1, QTableWidgetItem(str(stats['count'])))
self.artistsTable.setItem(i, 2, QTableWidgetItem(format_size(stats['bytes'])))
def updateArtistsChart(self, artist_stats, top_n):
self.artistsFigure.clear()
if not artist_stats:
self.artistsCanvas.draw()
return
# Sort by transfer count
sorted_artists = sorted(artist_stats.items(), key=lambda x: x[1]['count'], reverse=True)[:top_n]
ax = self.artistsFigure.add_subplot(111)
artists = [item[0] for item in sorted_artists]
counts = [item[1]['count'] for item in sorted_artists]
# Truncate long artist names for display
max_name_length = 25
truncated_artists = []
for artist in artists:
if len(artist) > max_name_length:
truncated_artists.append(artist[:max_name_length-3] + "...")
else:
truncated_artists.append(artist)
# Create horizontal bar chart
bars = ax.barh(range(len(truncated_artists)), counts)
ax.set_yticks(range(len(truncated_artists)))
ax.set_yticklabels(truncated_artists, fontsize=8)
ax.set_xlabel('Uploads')
ax.set_title(f'Top {len(artists)} Artists by Uploads')
# Add value labels on bars
for i, (bar, count) in enumerate(zip(bars, counts)):
ax.text(bar.get_width() + max(counts) * 0.01, bar.get_y() + bar.get_height()/2,
str(count), ha='left', va='center', fontsize=8)
# Expand x-axis to accommodate labels
ax.set_xlim(0, max(counts) * 1.15)
ax.grid(axis='x', alpha=0.3)
self.artistsFigure.tight_layout()
# Add custom hover functionality
self.artistsCanvas.mpl_connect('motion_notify_event',
lambda event: self.onArtistHover(event, bars, artists, counts))
self.artistsCanvas.draw()
def updateAlbumsTable(self, album_stats, top_n):
# Sort by transfer count
sorted_albums = sorted(album_stats.items(), key=lambda x: x[1]['count'], reverse=True)[:top_n]
self.albumsTable.setRowCount(len(sorted_albums))
for i, (album_key, stats) in enumerate(sorted_albums):
artist, album = album_key
self.albumsTable.setItem(i, 0, QTableWidgetItem(artist))
self.albumsTable.setItem(i, 1, QTableWidgetItem(album))
self.albumsTable.setItem(i, 2, QTableWidgetItem(str(stats['count'])))
self.albumsTable.setItem(i, 3, QTableWidgetItem(format_size(stats['bytes'])))
def updateAlbumsChart(self, album_stats, top_n):
self.albumsFigure.clear()
if not album_stats:
self.albumsCanvas.draw()
return
# Sort by transfer count
sorted_albums = sorted(album_stats.items(), key=lambda x: x[1]['count'], reverse=True)[:top_n]
ax = self.albumsFigure.add_subplot(111)
album_labels = [item[0][1] for item in sorted_albums] # Just album name, not artist
counts = [item[1]['count'] for item in sorted_albums]
# Truncate long album labels for display
max_label_length = 30
truncated_labels = []
for label in album_labels:
if len(label) > max_label_length:
truncated_labels.append(label[:max_label_length-3] + "...")
else:
truncated_labels.append(label)
# Create horizontal bar chart
bars = ax.barh(range(len(truncated_labels)), counts)
ax.set_yticks(range(len(truncated_labels)))
ax.set_yticklabels(truncated_labels, fontsize=7)
ax.set_xlabel('Uploads')
ax.set_title(f'Top {len(album_labels)} Albums by Uploads')
# Add value labels on bars
for i, (bar, count) in enumerate(zip(bars, counts)):
ax.text(bar.get_width() + max(counts) * 0.01, bar.get_y() + bar.get_height()/2,
str(count), ha='left', va='center', fontsize=7)
# Expand x-axis to accommodate labels
ax.set_xlim(0, max(counts) * 1.15)
ax.grid(axis='x', alpha=0.3)
try:
self.albumsFigure.tight_layout()
except:
pass # Ignore tight_layout warnings for long album names
# Add custom hover functionality (pass full artist-album info for tooltips)
full_album_labels = [f"{item[0][0]} - {item[0][1]}" for item in sorted_albums]
self.albumsCanvas.mpl_connect('motion_notify_event',
lambda event: self.onAlbumHover(event, bars, full_album_labels, counts))
self.albumsCanvas.draw()
def onArtistHover(self, event, bars, artists, counts):
"""Handle hover events on artist chart bars"""
if event.inaxes is None:
self.artistsCanvas.setToolTip("")
return
for i, bar in enumerate(bars):
if bar.contains(event)[0]:
# Show tooltip with full artist name and count
tooltip_text = f"{artists[i]}\n{counts[i]} uploads"
self.artistsCanvas.setToolTip(tooltip_text)
return
# Clear tooltip when not hovering over a bar
self.artistsCanvas.setToolTip("")
def onAlbumHover(self, event, bars, album_labels, counts):
"""Handle hover events on album chart bars"""
if event.inaxes is None:
self.albumsCanvas.setToolTip("")
return
for i, bar in enumerate(bars):
if bar.contains(event)[0]:
# Show tooltip with full album name and count
tooltip_text = f"{album_labels[i]}\n{counts[i]} uploads"
self.albumsCanvas.setToolTip(tooltip_text)
return
# Clear tooltip when not hovering over a bar
self.albumsCanvas.setToolTip("")
def showPopularityError(self, message, format_info):
"""Show error message and clear popularity displays"""
# Clear tables and charts
self.artistsTable.setRowCount(0)
self.albumsTable.setRowCount(0)
self.artistsFigure.clear()
self.albumsFigure.clear()
# Show explanatory text in charts
self.showPopularityExplanation(self.artistsFigure, self.artistsCanvas, message, format_info)
self.showPopularityExplanation(self.albumsFigure, self.albumsCanvas, message, format_info)
def showPopularityWarning(self, format_info):
"""Show warning about library format compatibility"""
warning_msg = f"Library format compatibility: {format_info['match_percentage']:.1f}%"
print(f"Warning: {warning_msg}") # Console warning
def showPopularityExplanation(self, figure, canvas, message, format_info):
"""Show explanation text in the chart area"""
ax = figure.add_subplot(111)
ax.axis('off')
explanation_text = f"""Popularity Stats Requirements:
{message}
How it works:
• Analyzes successful upload transfers only
• Extracts artist and album from file paths
• Expected format: /path/Music/Artist/Album/Track.ext
Library Analysis:
• Total files analyzed: {format_info['total_files']}
• Compatible files: {format_info['matching_files']} ({format_info['match_percentage']:.1f}%)
Example paths from your library:"""
if format_info['sample_paths']:
explanation_text += "\n\n" + "\n".join(f"{path}" for path in format_info['sample_paths'][:5])
if format_info['match_percentage'] < 50:
explanation_text += f"""
⚠️ Low compatibility detected ({format_info['match_percentage']:.1f}%)
Your music library structure may not match the expected format.
Consider organizing music files as: /Music/Artist/Album/Track.ext"""
ax.text(0.05, 0.95, explanation_text, transform=ax.transAxes,
fontsize=9, verticalalignment='top', fontfamily='monospace',
bbox=dict(boxstyle="round,pad=0.5", facecolor="lightgray", alpha=0.8))
canvas.draw()
def analyze_library_format(db_paths):
"""Analyze the library format to determine if it matches expected structure"""
total_files = 0
matching_files = 0
sample_paths = []
for db_path in db_paths:
try:
conn = sqlite3.connect(db_path)
cursor = conn.cursor()
# Get a sample of successful upload filenames
cursor.execute("""
SELECT Filename
FROM Transfers
WHERE State = 48 AND Direction = 'Upload' AND Filename IS NOT NULL
LIMIT 100
""")
rows = cursor.fetchall()
for (filename,) in rows:
total_files += 1
sample_paths.append(filename)
# Check if it matches expected format: /path/Music/{Artist}/{Album}/{Track}
if filename and '/Music/' in filename:
try:
parts = filename.split('/Music/')
if len(parts) > 1:
music_path = parts[1]
path_parts = music_path.split('/')
if len(path_parts) >= 2 and path_parts[0] and path_parts[1]:
matching_files += 1
except:
continue
conn.close()
except sqlite3.Error:
continue
match_percentage = (matching_files / total_files * 100) if total_files > 0 else 0
return {
'total_files': total_files,
'matching_files': matching_files,
'match_percentage': match_percentage,
'sample_paths': sample_paths[:10] # Keep first 10 as examples
}
def clean_album_name(artist, album):
"""Smart cleaning of album names to remove redundant artist information"""
if not artist or not album:
return album
artist_lower = artist.lower().strip()
album_lower = album.lower().strip()
# Common patterns to clean
patterns_to_try = [
# Pattern: "Artist - Album Title"
f"{artist_lower} - ",
f"{artist_lower} ", # em dash
f"{artist_lower}", # em dash variant
# Pattern: "Artist: Album Title"
f"{artist_lower}: ",
f"{artist_lower} : ",
# Pattern: "Artist_ Album Title" or similar separators
f"{artist_lower}_ ",
f"{artist_lower} _ ",
]
cleaned_album = album
for pattern in patterns_to_try:
if album_lower.startswith(pattern):
cleaned_album = album[len(pattern):]
break
# Additional cleaning: remove extra whitespace and dashes
cleaned_album = cleaned_album.strip(' -–—_')
# If cleaning resulted in empty string, return original
if not cleaned_album:
return album
# If cleaning removed too much (less than 3 chars), return original
if len(cleaned_album) < 3:
return album
return cleaned_album
def get_popularity_stats(db_paths, days=None):
"""Get artist and album popularity statistics from successful transfers"""
artist_stats = defaultdict(lambda: {'count': 0, 'bytes': 0})
album_stats = defaultdict(lambda: {'count': 0, 'bytes': 0})
# Create WHERE clause for time filtering
where_clause = "WHERE State = 48 AND Direction = 'Upload'" # Only successful uploads
params = []
if days is not None:
where_clause += " AND RequestedAt >= ?"
cutoff_date = (datetime.datetime.now() - datetime.timedelta(days=days)).isoformat()
params.append(cutoff_date)
for db_path in db_paths:
try:
conn = sqlite3.connect(db_path)
cursor = conn.cursor()
query = f"""
SELECT Filename, Size
FROM Transfers
{where_clause}
"""
cursor.execute(query, params)
rows = cursor.fetchall()
for filename, size in rows:
# Parse filename to extract artist and album
# Expected format: /data/Music/{Artist}/{Album}/{Track}
if filename and '/Music/' in filename:
try:
parts = filename.split('/Music/')
if len(parts) > 1:
music_path = parts[1]
path_parts = music_path.split('/')
if len(path_parts) >= 2:
artist = path_parts[0]
raw_album = path_parts[1]
# Clean album name to remove redundant artist info
cleaned_album = clean_album_name(artist, raw_album)
# Update artist stats
artist_stats[artist]['count'] += 1
artist_stats[artist]['bytes'] += size
# Update album stats using cleaned album name
album_key = (artist, cleaned_album)
album_stats[album_key]['count'] += 1
album_stats[album_key]['bytes'] += size
except:
# Skip files that don't match expected format
continue
conn.close()
except sqlite3.Error as e:
print(f"Database error for {db_path}: {e}")
continue
return dict(artist_stats), dict(album_stats)
def main(): def main():
# Launch GUI application # Launch GUI application