Update GUI with dropdown time periods and backwards compatibility

- Replace spinbox with dropdown for time periods (All time, Last month, Last year)
- Add backwards compatibility for both old and new database formats
- Detect database schema automatically and use appropriate queries
- Fix compatibility with newer transfers.db format (StateDescription vs State)
This commit is contained in:
Alec
2025-07-17 15:39:04 +02:00
commit f21c75adb0
2 changed files with 722 additions and 0 deletions
+122
View File
@@ -0,0 +1,122 @@
# slskd Transfer Statistics
A tool to analyze upload and download statistics from your slskd transfers database with both command-line and GUI interfaces.
## Features
- Analyzes uploads and downloads stored in the transfers.db database(s)
- Automatically finds and combines data from multiple database files
- Calculates total transfers, data transferred, and unique users
- Shows average transfer speed and duration
- Lists top users by data transferred
- Shows statistics by file type
- Filter statistics by date range
- Graphical user interface for easier analysis
- Maintains full command-line functionality
## Requirements
- Python 3.6+
- SQLite3
- PyQt5 (for GUI version)
## Installation
1. Clone or download this repository to your local machine
2. Ensure Python 3 is installed
3. Place your `transfers.db` file in the same directory as the script, or specify database paths using the `--db` option
## Usage
### Command Line Interface
```bash
# Basic usage (uses transfers.db in current directory)
# Shows both upload and download stats by default
./slskd_stats.py
# Show only upload stats
./slskd_stats.py --uploads
# Show only download stats
./slskd_stats.py --downloads
# Explicitly show both upload and download stats (same as default)
./slskd_stats.py --all
# Specify single database file
./slskd_stats.py --db /path/to/transfers.db
# Specify multiple database files
./slskd_stats.py --db /path/to/transfers.db --db /path/to/another-transfers.db
# Only show transfers from the last 30 days
./slskd_stats.py --days 30
# Show top 15 entries in each category
./slskd_stats.py --top 15
# Combine options
./slskd_stats.py --all --days 7 --top 20 --db /path/to/transfers.db
```
### GUI Interface
```bash
# Launch the GUI version
./slskd_stats_gui.py
# Launch GUI even when providing command line arguments
./slskd_stats_gui.py --gui
```
With the GUI, you can:
- Select one or more database files using the file browser
- Choose to show upload stats, download stats, or both
- Filter by time period (last X days)
- Set the number of top entries to display
- View statistics in a user-friendly tabbed interface
## Example Output
```
=== UPLOAD STATISTICS ===
Total Uploads: 8583
Total Data Uploaded: 241.97 GB
Unique Users: 650
Average Upload Speed: 8.50 MB/s
Average Upload Duration: 10.02 seconds
--- Top Users by Data Uploaded ---
1. username1: 279 files, 18.01 GB
2. username2: 494 files, 12.29 GB
3. username3: 378 files, 11.05 GB
...
--- Top File Types ---
1. .flac: 8456 files, 241.00 GB
2. .mp3: 105 files, 830.71 MB
3. .m4a: 22 files, 165.58 MB
=== DOWNLOAD STATISTICS ===
Total Downloads: 357
Total Data Downloaded: 10.94 GB
Unique Users: 36
Average Download Speed: 2.03 MB/s
Average Download Duration: 26.21 seconds
--- Top Users by Data Downloaded ---
1. username1: 6 files, 1.45 GB
2. username2: 58 files, 1.33 GB
...
```
## About
This tool is designed to work with the `transfers.db` SQLite database created by [slskd](https://github.com/slskd/slskd), a Soulseek client daemon. It helps you understand your sharing patterns and track transfer statistics.
## License
MIT
+600
View File
@@ -0,0 +1,600 @@
#!/usr/bin/env python3
import sys
import os
import datetime
import sqlite3
import argparse
import glob
from collections import defaultdict
from pathlib import Path
# For GUI
from PyQt5.QtWidgets import (
QApplication, QMainWindow, QWidget, QVBoxLayout, QHBoxLayout,
QPushButton, QLabel, QFileDialog, QComboBox, QSplitter,
QTableWidget, QTableWidgetItem, QHeaderView, QCheckBox,
QSpinBox, QGroupBox, QFormLayout, QTextEdit, QMessageBox
)
from PyQt5.QtCore import Qt, QSize
from PyQt5.QtGui import QFont, QIcon
def format_size(size_bytes):
"""Format byte size to human readable format"""
if size_bytes == 0:
return "0B"
size_names = ("B", "KB", "MB", "GB", "TB")
i = 0
while size_bytes >= 1024 and i < len(size_names) - 1:
size_bytes /= 1024
i += 1
return f"{size_bytes:.2f} {size_names[i]}"
def format_time(seconds):
"""Format seconds to human readable time"""
if seconds < 60:
return f"{seconds:.2f} seconds"
elif seconds < 3600:
minutes = seconds / 60
return f"{minutes:.2f} minutes"
else:
hours = seconds / 3600
return f"{hours:.2f} hours"
def check_database_format(db_path):
"""Check if database uses old (text State) or new (integer State + StateDescription) format"""
try:
conn = sqlite3.connect(db_path)
cursor = conn.cursor()
# Check if StateDescription column exists
cursor.execute("PRAGMA table_info(Transfers)")
columns = [column[1] for column in cursor.fetchall()]
has_state_description = 'StateDescription' in columns
if has_state_description:
# New format with StateDescription column
conn.close()
return 'new'
else:
# Old format with text State column
conn.close()
return 'old'
except sqlite3.Error:
return 'old' # Default to old format if error
def get_transfer_stats(db_paths, direction="Upload", days=None):
"""Get statistics on transfers from the database(s)"""
stats = {
"total_transfers": 0,
"total_bytes": 0,
"unique_users": set(),
"user_stats": defaultdict(lambda: {"count": 0, "bytes": 0}),
"extension_stats": defaultdict(lambda: {"count": 0, "bytes": 0}),
"speeds": [],
"durations": [],
"total_attempts": 0,
"errors": 0
}
date_filter = ""
if days:
cutoff_date = (datetime.datetime.now() - datetime.timedelta(days=days)).strftime("%Y-%m-%d")
date_filter = f" AND RequestedAt >= '{cutoff_date}'"
# Process each database file
for db_path in db_paths:
if not os.path.exists(db_path):
print(f"Warning: Database file not found: {db_path}")
continue
try:
conn = sqlite3.connect(db_path)
cursor = conn.cursor()
# Detect database format
db_format = check_database_format(db_path)
if db_format == 'new':
# New format with StateDescription column
completed_condition = "StateDescription LIKE 'Completed%'"
error_condition = "StateDescription='Completed, Errored'"
success_condition = "StateDescription='Completed, Succeeded'"
else:
# Old format with text State column
completed_condition = "State LIKE 'Completed%'"
error_condition = "State='Completed, Errored'"
success_condition = "State LIKE 'Completed, Succeeded'"
# Count total attempts and errors for error rate
cursor.execute(f"""
SELECT COUNT(*) FROM Transfers
WHERE Direction=? AND {completed_condition}
{date_filter}
""", (direction,))
stats["total_attempts"] += cursor.fetchone()[0]
cursor.execute(f"""
SELECT COUNT(*) FROM Transfers
WHERE Direction=? AND {error_condition}
{date_filter}
""", (direction,))
stats["errors"] += cursor.fetchone()[0]
# Get successful transfers
cursor.execute(f"""
SELECT Username, Filename, Size, BytesTransferred, AverageSpeed,
RequestedAt, StartedAt, EndedAt
FROM Transfers
WHERE Direction=? AND {success_condition}
{date_filter}
""", (direction,))
transfers = cursor.fetchall()
conn.close()
# Process transfer data
for row in transfers:
username, filename, size, bytes_transferred, avg_speed, req_at, start_at, end_at = row
stats["total_transfers"] += 1
stats["total_bytes"] += bytes_transferred
stats["unique_users"].add(username)
# User stats
stats["user_stats"][username]["count"] += 1
stats["user_stats"][username]["bytes"] += bytes_transferred
# Extension stats
ext = os.path.splitext(filename)[1].lower() if filename else ".unknown"
if not ext:
ext = ".noext"
stats["extension_stats"][ext]["count"] += 1
stats["extension_stats"][ext]["bytes"] += bytes_transferred
# Speed stats
if avg_speed > 0:
stats["speeds"].append(avg_speed)
# Duration stats
if start_at and end_at:
try:
start_time = datetime.datetime.fromisoformat(start_at.replace('Z', '+00:00'))
end_time = datetime.datetime.fromisoformat(end_at.replace('Z', '+00:00'))
duration = (end_time - start_time).total_seconds()
if duration > 0:
stats["durations"].append(duration)
except (ValueError, TypeError):
pass
except sqlite3.Error as e:
print(f"Error processing database {db_path}: {e}")
# Calculate averages
if stats["speeds"]:
stats["avg_speed"] = sum(stats["speeds"]) / len(stats["speeds"])
else:
stats["avg_speed"] = 0
if stats["durations"]:
stats["avg_duration"] = sum(stats["durations"]) / len(stats["durations"])
else:
stats["avg_duration"] = 0
# Calculate error rate
if stats["total_attempts"] > 0:
stats["error_rate"] = (stats["errors"] / stats["total_attempts"]) * 100
else:
stats["error_rate"] = 0
stats["unique_users"] = len(stats["unique_users"])
return stats
def display_stats(stats, direction, top_n=10):
"""Display the statistics in a readable format for CLI"""
if not stats or stats["total_transfers"] == 0:
print(f"No {direction.lower()} data found for the specified period.")
return
output = []
output.append(f"\n=== {direction.upper()} STATISTICS ===\n")
output.append(f"Total {direction}s: {stats['total_transfers']}")
output.append(f"Total Data {direction}ed: {format_size(stats['total_bytes'])}")
output.append(f"Unique Users: {stats['unique_users']}")
output.append(f"Average {direction} Speed: {format_size(stats['avg_speed'])}/s")
output.append(f"Average {direction} Duration: {format_time(stats['avg_duration'])}")
# Top users by data transferred
output.append(f"\n--- Top Users by Data {direction}ed ---")
sorted_users = sorted(
stats["user_stats"].items(),
key=lambda x: x[1]["bytes"],
reverse=True
)
for i, (username, data) in enumerate(sorted_users[:top_n], 1):
output.append(f"{i}. {username}: {data['count']} files, {format_size(data['bytes'])}")
# Top file types
output.append("\n--- Top File Types ---")
sorted_extensions = sorted(
stats["extension_stats"].items(),
key=lambda x: x[1]["bytes"],
reverse=True
)
for i, (ext, data) in enumerate(sorted_extensions[:top_n], 1):
output.append(f"{i}. {ext}: {data['count']} files, {format_size(data['bytes'])}")
# Print all lines
for line in output:
print(line)
return output
class MainWindow(QMainWindow):
def __init__(self):
super().__init__()
self.setWindowTitle("slskd Transfer Statistics")
self.setMinimumSize(800, 600)
self.db_paths = []
# Create central widget and main layout
self.centralWidget = QWidget()
self.setCentralWidget(self.centralWidget)
self.mainLayout = QVBoxLayout(self.centralWidget)
# Create top section for database selection
self.createDatabaseSection()
# Create filter section
self.createFilterSection()
# Create tabs for results
self.createResultsTabs()
# Create analyze button
self.analyzeButton = QPushButton("Analyze Transfers")
self.analyzeButton.clicked.connect(self.analyzeTransfers)
self.mainLayout.addWidget(self.analyzeButton)
# Initial database check
if os.path.exists("transfers.db"):
self.db_paths.append("transfers.db")
self.dbPathsLabel.setText("Database(s): transfers.db")
def createDatabaseSection(self):
# Database selection section
dbSection = QGroupBox("Database Files")
dbLayout = QVBoxLayout()
# Create layout for database selection buttons
buttonLayout = QHBoxLayout()
# Add database button
addDbButton = QPushButton("Add Database File")
addDbButton.clicked.connect(self.addDatabaseFile)
buttonLayout.addWidget(addDbButton)
# Clear databases button
clearDbButton = QPushButton("Clear Database Files")
clearDbButton.clicked.connect(self.clearDatabaseFiles)
buttonLayout.addWidget(clearDbButton)
dbLayout.addLayout(buttonLayout)
# Label to show selected databases
self.dbPathsLabel = QLabel("No database files selected")
dbLayout.addWidget(self.dbPathsLabel)
dbSection.setLayout(dbLayout)
self.mainLayout.addWidget(dbSection)
def createFilterSection(self):
# Filter options section
filterSection = QGroupBox("Filters")
filterLayout = QFormLayout()
# Time period filter
self.periodComboBox = QComboBox()
self.periodComboBox.addItems(["All time", "Last month", "Last year"])
self.periodComboBox.setCurrentText("All time")
filterLayout.addRow("Time period:", self.periodComboBox)
# Top N filter
self.topSpinBox = QSpinBox()
self.topSpinBox.setMinimum(1)
self.topSpinBox.setMaximum(100)
self.topSpinBox.setValue(10)
filterLayout.addRow("Show top N entries:", self.topSpinBox)
# Hidden variables to replace checkboxes
self.uploadsCheckBox = True
self.downloadsCheckBox = True
filterSection.setLayout(filterLayout)
self.mainLayout.addWidget(filterSection)
def createResultsTabs(self):
# Create a widget for all statistics
self.resultsWidget = QWidget()
self.resultsLayout = QVBoxLayout(self.resultsWidget)
# Summary section - horizontal layout with two text boxes
summarySection = QHBoxLayout()
# Upload summary
uploadSummaryGroup = QGroupBox("Upload Summary")
uploadSummaryLayout = QVBoxLayout()
self.uploadSummary = QTextEdit()
self.uploadSummary.setReadOnly(True)
self.uploadSummary.setMaximumHeight(120)
uploadSummaryLayout.addWidget(self.uploadSummary)
uploadSummaryGroup.setLayout(uploadSummaryLayout)
summarySection.addWidget(uploadSummaryGroup)
# Download summary
downloadSummaryGroup = QGroupBox("Download Summary")
downloadSummaryLayout = QVBoxLayout()
self.downloadSummary = QTextEdit()
self.downloadSummary.setReadOnly(True)
self.downloadSummary.setMaximumHeight(120)
downloadSummaryLayout.addWidget(self.downloadSummary)
downloadSummaryGroup.setLayout(downloadSummaryLayout)
summarySection.addWidget(downloadSummaryGroup)
self.resultsLayout.addLayout(summarySection)
# Users section - horizontal layout with two tables
usersSection = QHBoxLayout()
# Upload users
uploadUsersGroup = QGroupBox("Top Users by Upload Size")
uploadUsersLayout = QVBoxLayout()
self.uploadUsersTable = QTableWidget(0, 3)
self.uploadUsersTable.setHorizontalHeaderLabels(["User", "Files", "Data"])
self.uploadUsersTable.horizontalHeader().setSectionResizeMode(QHeaderView.Stretch)
uploadUsersLayout.addWidget(self.uploadUsersTable)
uploadUsersGroup.setLayout(uploadUsersLayout)
usersSection.addWidget(uploadUsersGroup)
# Download users
downloadUsersGroup = QGroupBox("Top Users by Download Size")
downloadUsersLayout = QVBoxLayout()
self.downloadUsersTable = QTableWidget(0, 3)
self.downloadUsersTable.setHorizontalHeaderLabels(["User", "Files", "Data"])
self.downloadUsersTable.horizontalHeader().setSectionResizeMode(QHeaderView.Stretch)
downloadUsersLayout.addWidget(self.downloadUsersTable)
downloadUsersGroup.setLayout(downloadUsersLayout)
usersSection.addWidget(downloadUsersGroup)
self.resultsLayout.addLayout(usersSection)
# File types section - horizontal layout with two tables
typesSection = QHBoxLayout()
# Upload file types
uploadTypesGroup = QGroupBox("Top File Types (Uploads)")
uploadTypesLayout = QVBoxLayout()
self.uploadTypesTable = QTableWidget(0, 3)
self.uploadTypesTable.setHorizontalHeaderLabels(["Extension", "Files", "Data"])
self.uploadTypesTable.horizontalHeader().setSectionResizeMode(QHeaderView.Stretch)
uploadTypesLayout.addWidget(self.uploadTypesTable)
uploadTypesGroup.setLayout(uploadTypesLayout)
typesSection.addWidget(uploadTypesGroup)
# Download file types
downloadTypesGroup = QGroupBox("Top File Types (Downloads)")
downloadTypesLayout = QVBoxLayout()
self.downloadTypesTable = QTableWidget(0, 3)
self.downloadTypesTable.setHorizontalHeaderLabels(["Extension", "Files", "Data"])
self.downloadTypesTable.horizontalHeader().setSectionResizeMode(QHeaderView.Stretch)
downloadTypesLayout.addWidget(self.downloadTypesTable)
downloadTypesGroup.setLayout(downloadTypesLayout)
typesSection.addWidget(downloadTypesGroup)
self.resultsLayout.addLayout(typesSection)
# Add the results widget to the main layout
self.mainLayout.addWidget(self.resultsWidget)
def addDatabaseFile(self):
options = QFileDialog.Options()
files, _ = QFileDialog.getOpenFileNames(
self, "Select Database File(s)", "", "SQLite Files (*.db);;All Files (*)",
options=options
)
if files:
for file in files:
if file not in self.db_paths:
self.db_paths.append(file)
self.updateDbPathsLabel()
def clearDatabaseFiles(self):
self.db_paths = []
self.dbPathsLabel.setText("No database files selected")
def updateDbPathsLabel(self):
if not self.db_paths:
self.dbPathsLabel.setText("No database files selected")
else:
paths_text = ", ".join(os.path.basename(path) for path in self.db_paths)
self.dbPathsLabel.setText(f"Database(s): {paths_text}")
def populateTable(self, table, data, top_n):
table.setRowCount(0)
for i, (name, stats) in enumerate(data[:top_n]):
table.insertRow(i)
table.setItem(i, 0, QTableWidgetItem(name))
table.setItem(i, 1, QTableWidgetItem(str(stats["count"])))
table.setItem(i, 2, QTableWidgetItem(format_size(stats["bytes"])))
def analyzeTransfers(self):
if not self.db_paths:
QMessageBox.warning(self, "No Database Files",
"Please add at least one database file to analyze.")
return
# Convert period selection to days
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.topSpinBox.value()
# Always show both upload and download stats
show_uploads = True
show_downloads = True
# Clear previous results
self.uploadSummary.clear()
self.downloadSummary.clear()
self.uploadUsersTable.setRowCount(0)
self.downloadUsersTable.setRowCount(0)
self.uploadTypesTable.setRowCount(0)
self.downloadTypesTable.setRowCount(0)
# Get stats and update UI
upload_stats = get_transfer_stats(self.db_paths, "Upload", days)
if upload_stats["total_transfers"] > 0:
stats_text = "\n".join([
f"Total Uploads: {upload_stats['total_transfers']}",
f"Total Data Uploaded: {format_size(upload_stats['total_bytes'])}",
f"Unique Users: {upload_stats['unique_users']}",
f"Average Upload Speed: {format_size(upload_stats['avg_speed'])}/s",
f"Average Upload Duration: {format_time(upload_stats['avg_duration'])}",
f"Error Rate: {upload_stats['error_rate']:.2f}% ({upload_stats['errors']} of {upload_stats['total_attempts']})"
])
self.uploadSummary.setText(stats_text)
# Update tables
sorted_users = sorted(
upload_stats["user_stats"].items(),
key=lambda x: x[1]["bytes"],
reverse=True
)
self.populateTable(self.uploadUsersTable, sorted_users, top_n)
sorted_extensions = sorted(
upload_stats["extension_stats"].items(),
key=lambda x: x[1]["bytes"],
reverse=True
)
self.populateTable(self.uploadTypesTable, sorted_extensions, top_n)
else:
self.uploadSummary.setText("No upload data found for the specified period.")
download_stats = get_transfer_stats(self.db_paths, "Download", days)
if download_stats["total_transfers"] > 0:
stats_text = "\n".join([
f"Total Downloads: {download_stats['total_transfers']}",
f"Total Data Downloaded: {format_size(download_stats['total_bytes'])}",
f"Unique Users: {download_stats['unique_users']}",
f"Average Download Speed: {format_size(download_stats['avg_speed'])}/s",
f"Average Download Duration: {format_time(download_stats['avg_duration'])}",
f"Error Rate: {download_stats['error_rate']:.2f}% ({download_stats['errors']} of {download_stats['total_attempts']})"
])
self.downloadSummary.setText(stats_text)
# Update tables
sorted_users = sorted(
download_stats["user_stats"].items(),
key=lambda x: x[1]["bytes"],
reverse=True
)
self.populateTable(self.downloadUsersTable, sorted_users, top_n)
sorted_extensions = sorted(
download_stats["extension_stats"].items(),
key=lambda x: x[1]["bytes"],
reverse=True
)
self.populateTable(self.downloadTypesTable, sorted_extensions, top_n)
else:
self.downloadSummary.setText("No download data found for the specified period.")
def main():
# Check if command line args were provided
if len(sys.argv) > 1:
# Command line mode
parser = argparse.ArgumentParser(description="Analyze transfer statistics from slskd transfers database")
parser.add_argument("--db", action="append", help="Path to transfers.db file(s). Can be specified multiple times.")
parser.add_argument("--days", type=int, help="Only analyze transfers from the last X days")
parser.add_argument("--top", type=int, default=10, help="Show top N entries in each category")
parser.add_argument("--uploads", action="store_true", help="Show only upload statistics")
parser.add_argument("--downloads", action="store_true", help="Show only download statistics")
parser.add_argument("--all", action="store_true", help="Show both upload and download statistics (default)")
parser.add_argument("--gui", action="store_true", help="Launch the GUI instead of command line mode")
args = parser.parse_args()
# Check if GUI mode is requested
if args.gui:
# Launch GUI mode
app = QApplication(sys.argv)
window = MainWindow()
window.show()
sys.exit(app.exec_())
# Determine which db files to use
db_paths = []
if args.db:
# User specified database files
for db_path in args.db:
if os.path.exists(db_path):
db_paths.append(db_path)
else:
print(f"Warning: Database file not found: {db_path}")
else:
# Default: Only look for transfers.db in the current directory
if os.path.exists("transfers.db"):
db_paths.append("transfers.db")
else:
print("Default transfers.db not found in current directory.")
if not db_paths:
print("Error: No database files found. Please specify with --db option.")
return
print(f"Using database file(s): {', '.join(db_paths)}")
# Default behavior is to show both uploads and downloads
# Only change from default if specific flags are set
if args.uploads and not args.downloads and not args.all:
show_uploads = True
show_downloads = False
elif args.downloads and not args.uploads and not args.all:
show_uploads = False
show_downloads = True
else:
# Default behavior or --all flag
show_uploads = True
show_downloads = True
if show_uploads:
upload_stats = get_transfer_stats(db_paths, "Upload", args.days)
display_stats(upload_stats, "Upload", args.top)
if show_downloads:
download_stats = get_transfer_stats(db_paths, "Download", args.days)
display_stats(download_stats, "Download", args.top)
else:
# No args provided, launch GUI
app = QApplication(sys.argv)
window = MainWindow()
window.show()
sys.exit(app.exec_())
if __name__ == "__main__":
main()