updates
This commit is contained in:
10
.claude/settings.local.json
Normal file
10
.claude/settings.local.json
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
{
|
||||||
|
"permissions": {
|
||||||
|
"allow": [
|
||||||
|
"Bash(source:*)",
|
||||||
|
"Bash(python -m jellycleanarr:*)",
|
||||||
|
"Bash(uv run python:*)",
|
||||||
|
"Bash(.venv/Scripts/python.exe:*)"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
13
.env.example
Normal file
13
.env.example
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
# Jellycleanarr Configuration
|
||||||
|
|
||||||
|
# Jellyfin server address (including http:// or https://)
|
||||||
|
JELLYFIN_ADDRESS=https://jellyfin.example.com
|
||||||
|
|
||||||
|
# Jellyfin API key (get this from Jellyfin Dashboard -> API Keys)
|
||||||
|
JELLYFIN_API_KEY=your_api_key_here
|
||||||
|
|
||||||
|
# Optional: Jellyfin username (for future authentication methods)
|
||||||
|
# JELLYFIN_USERNAME=your_username
|
||||||
|
|
||||||
|
# Optional: Cache TTL in seconds (default: 300)
|
||||||
|
# CACHE_TTL=300
|
||||||
140
.gitignore
vendored
Normal file
140
.gitignore
vendored
Normal file
@@ -0,0 +1,140 @@
|
|||||||
|
# Byte-compiled / optimized / DLL files
|
||||||
|
__pycache__/
|
||||||
|
*.py[cod]
|
||||||
|
*$py.class
|
||||||
|
|
||||||
|
# C extensions
|
||||||
|
*.so
|
||||||
|
|
||||||
|
# Distribution / packaging
|
||||||
|
.Python
|
||||||
|
build/
|
||||||
|
develop-eggs/
|
||||||
|
dist/
|
||||||
|
downloads/
|
||||||
|
eggs/
|
||||||
|
.eggs/
|
||||||
|
lib/
|
||||||
|
lib64/
|
||||||
|
parts/
|
||||||
|
sdist/
|
||||||
|
var/
|
||||||
|
wheels/
|
||||||
|
pip-wheel-metadata/
|
||||||
|
share/python-wheels/
|
||||||
|
*.egg-info/
|
||||||
|
.installed.cfg
|
||||||
|
*.egg
|
||||||
|
MANIFEST
|
||||||
|
|
||||||
|
# PyInstaller
|
||||||
|
*.manifest
|
||||||
|
*.spec
|
||||||
|
|
||||||
|
# Installer logs
|
||||||
|
pip-log.txt
|
||||||
|
pip-delete-this-directory.txt
|
||||||
|
|
||||||
|
# Unit test / coverage reports
|
||||||
|
htmlcov/
|
||||||
|
.tox/
|
||||||
|
.nox/
|
||||||
|
.coverage
|
||||||
|
.coverage.*
|
||||||
|
.cache
|
||||||
|
nosetests.xml
|
||||||
|
coverage.xml
|
||||||
|
*.cover
|
||||||
|
*.py,cover
|
||||||
|
.hypothesis/
|
||||||
|
.pytest_cache/
|
||||||
|
|
||||||
|
# Translations
|
||||||
|
*.mo
|
||||||
|
*.pot
|
||||||
|
|
||||||
|
# Django stuff:
|
||||||
|
*.log
|
||||||
|
local_settings.py
|
||||||
|
db.sqlite3
|
||||||
|
db.sqlite3-journal
|
||||||
|
|
||||||
|
# Flask stuff:
|
||||||
|
instance/
|
||||||
|
.webassets-cache
|
||||||
|
|
||||||
|
# Scrapy stuff:
|
||||||
|
.scrapy
|
||||||
|
|
||||||
|
# Sphinx documentation
|
||||||
|
docs/_build/
|
||||||
|
|
||||||
|
# PyBuilder
|
||||||
|
target/
|
||||||
|
|
||||||
|
# Jupyter Notebook
|
||||||
|
.ipynb_checkpoints
|
||||||
|
|
||||||
|
# IPython
|
||||||
|
profile_default/
|
||||||
|
ipython_config.py
|
||||||
|
|
||||||
|
# pyenv
|
||||||
|
.python-version
|
||||||
|
|
||||||
|
# pipenv
|
||||||
|
Pipfile.lock
|
||||||
|
|
||||||
|
# PEP 582
|
||||||
|
__pypackages__/
|
||||||
|
|
||||||
|
# Celery stuff
|
||||||
|
celerybeat-schedule
|
||||||
|
celerybeat.pid
|
||||||
|
|
||||||
|
# SageMath parsed files
|
||||||
|
*.sage.py
|
||||||
|
|
||||||
|
# Environments
|
||||||
|
.env
|
||||||
|
.venv
|
||||||
|
env/
|
||||||
|
venv/
|
||||||
|
ENV/
|
||||||
|
env.bak/
|
||||||
|
venv.bak/
|
||||||
|
|
||||||
|
# Spyder project settings
|
||||||
|
.spyderproject
|
||||||
|
.spyproject
|
||||||
|
|
||||||
|
# Rope project settings
|
||||||
|
.ropeproject
|
||||||
|
|
||||||
|
# mkdocs documentation
|
||||||
|
/site
|
||||||
|
|
||||||
|
# mypy
|
||||||
|
.mypy_cache/
|
||||||
|
.dmypy.json
|
||||||
|
dmypy.json
|
||||||
|
|
||||||
|
# Pyre type checker
|
||||||
|
.pyre/
|
||||||
|
|
||||||
|
# Poetry
|
||||||
|
poetry.lock
|
||||||
|
|
||||||
|
# IDE
|
||||||
|
.vscode/
|
||||||
|
.idea/
|
||||||
|
*.swp
|
||||||
|
*.swo
|
||||||
|
*~
|
||||||
|
|
||||||
|
# OS
|
||||||
|
.DS_Store
|
||||||
|
Thumbs.db
|
||||||
|
|
||||||
|
# Claude plans
|
||||||
|
.claude/plans/
|
||||||
57
DEBUGGING_NOTES.md
Normal file
57
DEBUGGING_NOTES.md
Normal file
@@ -0,0 +1,57 @@
|
|||||||
|
# Debugging Notes - Blank TUI Issue
|
||||||
|
|
||||||
|
## Current Status
|
||||||
|
|
||||||
|
### ✅ What's Working
|
||||||
|
1. **API Connection**: Successfully connecting to Jellyfin server
|
||||||
|
2. **Data Fetching**: All API endpoints returning data correctly
|
||||||
|
- Users: 12 found
|
||||||
|
- Libraries: 3 found
|
||||||
|
- Playback stats: Query working, data converting from list-of-lists to dictionaries
|
||||||
|
3. **Stats Services**: All three services (user, series, library) generating correct statistics
|
||||||
|
4. **Table Row Formatting**: Data being formatted correctly for display
|
||||||
|
|
||||||
|
### ❌ What's Not Working
|
||||||
|
- **TUI Display**: Tables appear blank/empty in the terminal
|
||||||
|
- No error messages or exceptions occurring
|
||||||
|
- Layout renders (header, tabs, footer) but content area is blank
|
||||||
|
|
||||||
|
## Data Dump Analysis
|
||||||
|
|
||||||
|
See `jellycleanarr_data_dump.txt` for complete data verification showing:
|
||||||
|
- User "HRiggs" with 346 plays
|
||||||
|
- Series "Landman - s02e05" with 13 plays
|
||||||
|
- All table rows properly formatted as lists of strings
|
||||||
|
|
||||||
|
Example working data:
|
||||||
|
```python
|
||||||
|
['HRiggs', '346', '3h 9m', '153']
|
||||||
|
['TV', '204', '5d 1h 16m', '115']
|
||||||
|
```
|
||||||
|
|
||||||
|
## Fixes Applied
|
||||||
|
|
||||||
|
1. **API Client** (`src/jellycleanarr/api/client.py`)
|
||||||
|
- Fixed `query_playback_stats()` to convert API's list-of-lists format to list-of-dicts
|
||||||
|
- Maps column names from `data["colums"]` to result rows
|
||||||
|
|
||||||
|
2. **StatsTable** (`src/jellycleanarr/ui/widgets/stats_table.py`)
|
||||||
|
- Moved column initialization from `__init__` to `on_mount()`
|
||||||
|
- Added logging for debugging
|
||||||
|
|
||||||
|
3. **CSS** (`src/jellycleanarr/ui/styles/main.tcss`)
|
||||||
|
- Added explicit height for widget containers
|
||||||
|
|
||||||
|
## Possible Remaining Issues
|
||||||
|
|
||||||
|
1. **Widget Composition**: TabPane might not be passing through content properly
|
||||||
|
2. **CSS Cascading**: Height/sizing might not be cascading correctly
|
||||||
|
3. **Async Timing**: Data might load but tables refresh before columns are added
|
||||||
|
4. **Textual Version**: Possible compatibility issue with current Textual version
|
||||||
|
|
||||||
|
## Next Steps
|
||||||
|
|
||||||
|
1. Try running with Textual devtools: `textual console` then run app
|
||||||
|
2. Check Textual version: `pip show textual`
|
||||||
|
3. Try simplifying the widget hierarchy (remove TabPane, just show widgets directly)
|
||||||
|
4. Add more explicit sizing in CSS (px values instead of percentages)
|
||||||
64
FIXES.md
Normal file
64
FIXES.md
Normal file
@@ -0,0 +1,64 @@
|
|||||||
|
# Jellycleanarr TUI Fixes
|
||||||
|
|
||||||
|
## Issues Fixed
|
||||||
|
|
||||||
|
### 1. API Route Error - Data Format Mismatch
|
||||||
|
**Problem**: The Playback Reporting Plugin API returns results as lists of lists with separate column names, but the code was treating them as dictionaries.
|
||||||
|
|
||||||
|
**Fix**: Updated `client.py` to convert the API response format:
|
||||||
|
- The API returns `{"colums": [...], "results": [[...], [...]]}`
|
||||||
|
- Now converts this to a list of dictionaries by mapping column names to values
|
||||||
|
|
||||||
|
**Files Changed**:
|
||||||
|
- `src/jellycleanarr/api/client.py` (lines 96-156)
|
||||||
|
|
||||||
|
### 2. DataTable Column Initialization
|
||||||
|
**Problem**: DataTable columns were being added during `__init__`, which doesn't work in Textual's widget lifecycle.
|
||||||
|
|
||||||
|
**Fix**: Moved column initialization to the `on_mount()` method where the widget DOM is available.
|
||||||
|
|
||||||
|
**Files Changed**:
|
||||||
|
- `src/jellycleanarr/ui/widgets/stats_table.py`
|
||||||
|
|
||||||
|
### 3. Widget Height Configuration
|
||||||
|
**Problem**: Widget containers weren't getting proper height allocation.
|
||||||
|
|
||||||
|
**Fix**: Added explicit height rules in CSS for the stats widgets.
|
||||||
|
|
||||||
|
**Files Changed**:
|
||||||
|
- `src/jellycleanarr/ui/styles/main.tcss`
|
||||||
|
|
||||||
|
## Testing
|
||||||
|
|
||||||
|
All data services verified working:
|
||||||
|
- ✓ User stats: 7 users with playback data
|
||||||
|
- ✓ Series stats: 50+ items with playback data
|
||||||
|
- ✓ Library stats: 3 libraries tracked
|
||||||
|
|
||||||
|
## How to Run
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Using the virtual environment directly
|
||||||
|
.venv/Scripts/python.exe -m jellycleanarr
|
||||||
|
|
||||||
|
# Or using uv
|
||||||
|
uv run python -m jellycleanarr
|
||||||
|
```
|
||||||
|
|
||||||
|
## Keyboard Shortcuts
|
||||||
|
|
||||||
|
- `q` - Quit
|
||||||
|
- `r` - Refresh current tab
|
||||||
|
- `1` - Switch to User Statistics
|
||||||
|
- `2` - Switch to Series Statistics
|
||||||
|
- `3` - Switch to Library Statistics
|
||||||
|
|
||||||
|
## Expected Behavior
|
||||||
|
|
||||||
|
The TUI should now display:
|
||||||
|
1. Header with title
|
||||||
|
2. Three tabs (User Statistics, Series Statistics, Library Statistics)
|
||||||
|
3. Two tables per tab showing "Most" and "Least" statistics
|
||||||
|
4. Footer with keyboard shortcuts
|
||||||
|
|
||||||
|
Each table should show data loaded from your Jellyfin server via the Playback Reporting Plugin.
|
||||||
192
README.md
192
README.md
@@ -1,3 +1,193 @@
|
|||||||
# Jellycleanarr
|
# Jellycleanarr
|
||||||
|
|
||||||
A tui that allows you to see and delete old media
|
A Terminal User Interface (TUI) for viewing Jellyfin media playback statistics. Jellycleanarr connects to your Jellyfin server and displays detailed analytics about media consumption across users, series, and libraries.
|
||||||
|
|
||||||
|
## Features
|
||||||
|
|
||||||
|
- **User Statistics**: View most and least active users with play counts and watch duration
|
||||||
|
- **Series Statistics**: See which series and movies are most/least watched
|
||||||
|
- **Library Statistics**: Analyze playback activity across your media libraries
|
||||||
|
- **Tabbed Interface**: Easy navigation between different stat views
|
||||||
|
- **Real-time Data**: Refresh statistics on demand with a single keypress
|
||||||
|
- **Modern TUI**: Beautiful terminal interface powered by Textual
|
||||||
|
|
||||||
|
## Requirements
|
||||||
|
|
||||||
|
- Python 3.10 or higher
|
||||||
|
- Jellyfin server with the [Playback Reporting Plugin](https://github.com/jellyfin/jellyfin-plugin-playbackreporting) installed
|
||||||
|
- Jellyfin API key
|
||||||
|
|
||||||
|
## Installation
|
||||||
|
|
||||||
|
### Using Poetry (Recommended)
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Clone the repository
|
||||||
|
git clone https://github.com/yourusername/jellycleanarr.git
|
||||||
|
cd jellycleanarr
|
||||||
|
|
||||||
|
# Install dependencies
|
||||||
|
poetry install
|
||||||
|
|
||||||
|
# Run the application
|
||||||
|
poetry run jellycleanarr
|
||||||
|
```
|
||||||
|
|
||||||
|
### Using pip
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Clone the repository
|
||||||
|
git clone https://github.com/yourusername/jellycleanarr.git
|
||||||
|
cd jellycleanarr
|
||||||
|
|
||||||
|
# Install dependencies
|
||||||
|
pip install -e .
|
||||||
|
|
||||||
|
# Run the application
|
||||||
|
jellycleanarr
|
||||||
|
```
|
||||||
|
|
||||||
|
## Configuration
|
||||||
|
|
||||||
|
1. Copy the example environment file:
|
||||||
|
```bash
|
||||||
|
cp .env.example .env
|
||||||
|
```
|
||||||
|
|
||||||
|
2. Edit `.env` and configure your Jellyfin settings:
|
||||||
|
```bash
|
||||||
|
JELLYFIN_ADDRESS=https://your-jellyfin-server.com
|
||||||
|
JELLYFIN_API_KEY=your_api_key_here
|
||||||
|
```
|
||||||
|
|
||||||
|
### Getting a Jellyfin API Key
|
||||||
|
|
||||||
|
1. Open your Jellyfin web interface
|
||||||
|
2. Go to Dashboard → API Keys
|
||||||
|
3. Click "+" to create a new API key
|
||||||
|
4. Give it a name (e.g., "Jellycleanarr")
|
||||||
|
5. Copy the generated key to your `.env` file
|
||||||
|
|
||||||
|
## Usage
|
||||||
|
|
||||||
|
Run the application:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# With Poetry
|
||||||
|
poetry run jellycleanarr
|
||||||
|
|
||||||
|
# With pip install
|
||||||
|
jellycleanarr
|
||||||
|
|
||||||
|
# As a Python module
|
||||||
|
python -m jellycleanarr
|
||||||
|
```
|
||||||
|
|
||||||
|
### Keyboard Shortcuts
|
||||||
|
|
||||||
|
- `1` - Switch to User Statistics tab
|
||||||
|
- `2` - Switch to Series Statistics tab
|
||||||
|
- `3` - Switch to Library Statistics tab
|
||||||
|
- `r` - Refresh current tab data
|
||||||
|
- `q` - Quit application
|
||||||
|
- `Tab` - Navigate between tabs
|
||||||
|
- Arrow keys - Navigate within tables
|
||||||
|
|
||||||
|
## Features by Tab
|
||||||
|
|
||||||
|
### User Statistics Tab
|
||||||
|
- Most Active Users: Users with the highest play counts
|
||||||
|
- Least Active Users: Users with the lowest play counts
|
||||||
|
- Displays: Username, total plays, watch duration, unique items watched
|
||||||
|
|
||||||
|
### Series Statistics Tab
|
||||||
|
- Most Watched Series: Series/movies with the highest play counts
|
||||||
|
- Least Watched Series: Series/movies with the lowest play counts
|
||||||
|
- Displays: Title, total plays, watch duration, unique users
|
||||||
|
|
||||||
|
### Library Statistics Tab
|
||||||
|
- Most Active Libraries: Libraries with the highest playback activity
|
||||||
|
- Least Active Libraries: Libraries with the lowest playback activity
|
||||||
|
- Displays: Library name, total plays, watch duration, unique users, item count
|
||||||
|
|
||||||
|
## Development
|
||||||
|
|
||||||
|
### Project Structure
|
||||||
|
|
||||||
|
```
|
||||||
|
jellycleanarr/
|
||||||
|
├── src/jellycleanarr/
|
||||||
|
│ ├── api/ # Jellyfin API client
|
||||||
|
│ ├── models/ # Data models
|
||||||
|
│ ├── services/ # Business logic
|
||||||
|
│ ├── ui/ # TUI components
|
||||||
|
│ │ ├── screens/ # App screens
|
||||||
|
│ │ ├── widgets/ # UI widgets
|
||||||
|
│ │ └── styles/ # CSS styling
|
||||||
|
│ └── utils/ # Utility functions
|
||||||
|
├── tests/ # Test suite
|
||||||
|
└── pyproject.toml # Project dependencies
|
||||||
|
```
|
||||||
|
|
||||||
|
### Running Tests
|
||||||
|
|
||||||
|
```bash
|
||||||
|
poetry run pytest
|
||||||
|
```
|
||||||
|
|
||||||
|
### Code Formatting
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Format code with Black
|
||||||
|
poetry run black src/
|
||||||
|
|
||||||
|
# Lint with Ruff
|
||||||
|
poetry run ruff check src/
|
||||||
|
|
||||||
|
# Type checking with mypy
|
||||||
|
poetry run mypy src/
|
||||||
|
```
|
||||||
|
|
||||||
|
## Troubleshooting
|
||||||
|
|
||||||
|
### Connection Errors
|
||||||
|
|
||||||
|
If you get connection errors:
|
||||||
|
1. Verify `JELLYFIN_ADDRESS` is correct and includes `https://` or `http://`
|
||||||
|
2. Check that your Jellyfin server is accessible
|
||||||
|
3. Ensure the API key is valid
|
||||||
|
|
||||||
|
### Authentication Errors
|
||||||
|
|
||||||
|
If authentication fails:
|
||||||
|
1. Verify your API key is correct
|
||||||
|
2. Check that the API key hasn't been revoked in Jellyfin
|
||||||
|
3. Try creating a new API key
|
||||||
|
|
||||||
|
### No Data Displayed
|
||||||
|
|
||||||
|
If statistics don't show:
|
||||||
|
1. Ensure the Playback Reporting Plugin is installed and enabled
|
||||||
|
2. Check that there is playback activity recorded in your Jellyfin server
|
||||||
|
3. Try refreshing the data with the `r` key
|
||||||
|
|
||||||
|
## Dependencies
|
||||||
|
|
||||||
|
- [Textual](https://textual.textualize.io/) - Modern TUI framework
|
||||||
|
- [httpx](https://www.python-httpx.org/) - Async HTTP client
|
||||||
|
- [Pydantic](https://pydantic-docs.helpmanual.io/) - Data validation
|
||||||
|
- [python-dotenv](https://github.com/theskumar/python-dotenv) - Environment variable management
|
||||||
|
|
||||||
|
## License
|
||||||
|
|
||||||
|
MIT License - See LICENSE file for details
|
||||||
|
|
||||||
|
## Contributing
|
||||||
|
|
||||||
|
Contributions are welcome! Please feel free to submit a Pull Request.
|
||||||
|
|
||||||
|
## Acknowledgments
|
||||||
|
|
||||||
|
- Built with [Textual](https://textual.textualize.io/)
|
||||||
|
- Integrates with [Jellyfin](https://jellyfin.org/)
|
||||||
|
- Uses the [Jellyfin Playback Reporting Plugin](https://github.com/jellyfin/jellyfin-plugin-playbackreporting)
|
||||||
111
TROUBLESHOOTING.md
Normal file
111
TROUBLESHOOTING.md
Normal file
@@ -0,0 +1,111 @@
|
|||||||
|
# Troubleshooting Guide - Jellycleanarr TUI
|
||||||
|
|
||||||
|
## Data Verification ✅
|
||||||
|
|
||||||
|
The file **`jellycleanarr_data_dump.txt`** contains a complete dump of all API requests and responses.
|
||||||
|
|
||||||
|
**Key findings**:
|
||||||
|
- ✅ API connection working
|
||||||
|
- ✅ 12 users found
|
||||||
|
- ✅ 3 libraries found
|
||||||
|
- ✅ Playback stats queries working
|
||||||
|
- ✅ Data formatted correctly for tables
|
||||||
|
|
||||||
|
Example data that should display:
|
||||||
|
```
|
||||||
|
HRiggs: 346 plays, 3h 9m watch time
|
||||||
|
TV: 204 plays, 5d 1h 16m watch time
|
||||||
|
Landman - s02e05: 13 plays
|
||||||
|
```
|
||||||
|
|
||||||
|
## Issue: Blank TUI
|
||||||
|
|
||||||
|
The TUI header, tabs, and footer render, but the content area (tables) is blank.
|
||||||
|
|
||||||
|
### Fixes Applied
|
||||||
|
|
||||||
|
1. **API Client** - Fixed data format conversion from list-of-lists to dictionaries
|
||||||
|
2. **StatsTable** - Moved column initialization to proper lifecycle method
|
||||||
|
3. **CSS** - Added explicit height configuration
|
||||||
|
|
||||||
|
### Things to Try
|
||||||
|
|
||||||
|
#### 1. Run with Textual Developer Console
|
||||||
|
|
||||||
|
Open two terminals:
|
||||||
|
|
||||||
|
**Terminal 1:**
|
||||||
|
```bash
|
||||||
|
.venv/Scripts/python.exe -m textual console
|
||||||
|
```
|
||||||
|
|
||||||
|
**Terminal 2:**
|
||||||
|
```bash
|
||||||
|
.venv/Scripts/python.exe -m jellycleanarr
|
||||||
|
```
|
||||||
|
|
||||||
|
This will show you internal logs and help diagnose rendering issues.
|
||||||
|
|
||||||
|
#### 2. Check Terminal Compatibility
|
||||||
|
|
||||||
|
Some terminals don't render TUIs properly. Try running in:
|
||||||
|
- Windows Terminal (recommended)
|
||||||
|
- PowerShell
|
||||||
|
- Git Bash
|
||||||
|
- WSL terminal
|
||||||
|
|
||||||
|
#### 3. Verify Data Loading
|
||||||
|
|
||||||
|
Run the verification script:
|
||||||
|
```bash
|
||||||
|
.venv/Scripts/python.exe -c "import asyncio; from src.jellycleanarr.services.stats_service import StatsService; from src.jellycleanarr.api.client import JellyfinClient; from src.jellycleanarr.config import settings; async def test(): c = JellyfinClient(settings.jellyfin_address, settings.jellyfin_api_key); s = StatsService(c); m, l = await s.get_user_stats(limit=3); print(f'Users: {[(u.username, u.total_plays) for u in m]}'); await c.close(); asyncio.run(test())"
|
||||||
|
```
|
||||||
|
|
||||||
|
#### 4. Try Running with --dev Flag
|
||||||
|
|
||||||
|
```bash
|
||||||
|
.venv/Scripts/python.exe -m jellycleanarr --dev
|
||||||
|
```
|
||||||
|
|
||||||
|
#### 5. Check for Rendering in Specific Area
|
||||||
|
|
||||||
|
Try pressing:
|
||||||
|
- `r` to refresh
|
||||||
|
- `1`, `2`, `3` to switch tabs
|
||||||
|
- Resize the terminal window
|
||||||
|
|
||||||
|
Sometimes TUI elements need a refresh or resize to render properly.
|
||||||
|
|
||||||
|
#### 6. Update Textual
|
||||||
|
|
||||||
|
Current version: `^0.83.0`
|
||||||
|
|
||||||
|
Try updating to latest:
|
||||||
|
```bash
|
||||||
|
uv pip install --upgrade textual
|
||||||
|
```
|
||||||
|
|
||||||
|
## If Still Blank
|
||||||
|
|
||||||
|
The data is definitely loading correctly (verified in dump file). The issue is likely:
|
||||||
|
|
||||||
|
1. **CSS/Layout**: Tables exist but are hidden/collapsed
|
||||||
|
2. **Textual Bug**: Version-specific rendering issue
|
||||||
|
3. **Terminal**: Terminal emulator doesn't support required features
|
||||||
|
|
||||||
|
### Alternative: Export Data
|
||||||
|
|
||||||
|
Since the API is working, you could:
|
||||||
|
1. Use the dump file for analysis
|
||||||
|
2. Create a web-based version instead of TUI
|
||||||
|
3. Export data to CSV/JSON for external viewing
|
||||||
|
|
||||||
|
## Files for Review
|
||||||
|
|
||||||
|
- `jellycleanarr_data_dump.txt` - Complete API data dump (1590 lines)
|
||||||
|
- `DEBUGGING_NOTES.md` - Technical details on fixes applied
|
||||||
|
- `FIXES.md` - Summary of all fixes
|
||||||
|
|
||||||
|
## Contact/Support
|
||||||
|
|
||||||
|
If the issue persists, the problem is likely environmental (terminal, Textual version, etc.) rather than with the code logic, since all data fetching and processing is confirmed working.
|
||||||
557
app.js
Normal file
557
app.js
Normal file
@@ -0,0 +1,557 @@
|
|||||||
|
"use strict";
|
||||||
|
|
||||||
|
const state = {
|
||||||
|
catalog: null,
|
||||||
|
profileByItem: {},
|
||||||
|
selectedItem: null,
|
||||||
|
};
|
||||||
|
|
||||||
|
const dom = {
|
||||||
|
statusText: document.getElementById("statusText"),
|
||||||
|
catalogFile: document.getElementById("catalogFile"),
|
||||||
|
profileFile: document.getElementById("profileFile"),
|
||||||
|
exportProfile: document.getElementById("exportProfile"),
|
||||||
|
searchInput: document.getElementById("searchInput"),
|
||||||
|
categoryFilter: document.getElementById("categoryFilter"),
|
||||||
|
spawnFilter: document.getElementById("spawnFilter"),
|
||||||
|
itemTableBody: document.getElementById("itemTableBody"),
|
||||||
|
selectedDetails: document.getElementById("selectedDetails"),
|
||||||
|
resetSelected: document.getElementById("resetSelected"),
|
||||||
|
};
|
||||||
|
|
||||||
|
function setStatus(text) {
|
||||||
|
dom.statusText.textContent = text;
|
||||||
|
}
|
||||||
|
|
||||||
|
function readFileText(file) {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
const reader = new FileReader();
|
||||||
|
reader.onload = () => resolve(String(reader.result || ""));
|
||||||
|
reader.onerror = () => reject(new Error(`Failed to read ${file.name}`));
|
||||||
|
reader.readAsText(file);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function safeNumber(value, fallback = 0) {
|
||||||
|
const n = Number.parseFloat(value);
|
||||||
|
return Number.isFinite(n) ? n : fallback;
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeItemType(item) {
|
||||||
|
if (typeof item !== "string") {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
const trimmed = item.trim();
|
||||||
|
if (!trimmed) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return trimmed.includes(".") ? trimmed : `Base.${trimmed}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeCatalog(raw) {
|
||||||
|
if (!raw || !Array.isArray(raw.items)) {
|
||||||
|
throw new Error("Invalid catalog format. Missing items array.");
|
||||||
|
}
|
||||||
|
|
||||||
|
const lists = new Set(Array.isArray(raw.lists) ? raw.lists : []);
|
||||||
|
const items = [];
|
||||||
|
|
||||||
|
for (const rawItem of raw.items) {
|
||||||
|
const itemType = normalizeItemType(rawItem.item);
|
||||||
|
if (!itemType) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const aggregated = Array.isArray(rawItem.aggregatedPlacements)
|
||||||
|
? rawItem.aggregatedPlacements
|
||||||
|
: [];
|
||||||
|
const placements = {};
|
||||||
|
for (const placement of aggregated) {
|
||||||
|
const listName = placement && typeof placement.list === "string" ? placement.list.trim() : "";
|
||||||
|
const weight = safeNumber(placement && placement.weight, 0);
|
||||||
|
if (!listName || weight <= 0) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
placements[listName] = Number(weight.toFixed(6));
|
||||||
|
lists.add(listName);
|
||||||
|
}
|
||||||
|
|
||||||
|
items.push({
|
||||||
|
item: itemType,
|
||||||
|
shortId: typeof rawItem.shortId === "string" ? rawItem.shortId : itemType.replace(/^Base\./, ""),
|
||||||
|
category: typeof rawItem.category === "string" ? rawItem.category : "unknown",
|
||||||
|
defaultEnabled: rawItem.defaultEnabled !== false,
|
||||||
|
spawnControlKeys: Array.isArray(rawItem.spawnControlKeys) ? rawItem.spawnControlKeys : [],
|
||||||
|
defaultPlacements: placements,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
items.sort((a, b) => a.item.localeCompare(b.item));
|
||||||
|
return {
|
||||||
|
generatedAt: raw.generatedAt || null,
|
||||||
|
source: raw.source || {},
|
||||||
|
items,
|
||||||
|
lists: Array.from(lists).sort(),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function initializeProfileFromCatalog() {
|
||||||
|
const profileByItem = {};
|
||||||
|
for (const item of state.catalog.items) {
|
||||||
|
const placements = {};
|
||||||
|
for (const [listName, weight] of Object.entries(item.defaultPlacements)) {
|
||||||
|
placements[listName] = weight;
|
||||||
|
}
|
||||||
|
profileByItem[item.item] = {
|
||||||
|
item: item.item,
|
||||||
|
category: item.category,
|
||||||
|
enabled: item.defaultEnabled,
|
||||||
|
placements,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
state.profileByItem = profileByItem;
|
||||||
|
}
|
||||||
|
|
||||||
|
function getProfileEntry(itemType) {
|
||||||
|
return state.profileByItem[itemType] || null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateProfileEntry(itemType, updater) {
|
||||||
|
const current = getProfileEntry(itemType);
|
||||||
|
if (!current) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
updater(current);
|
||||||
|
}
|
||||||
|
|
||||||
|
function getFilteredItems() {
|
||||||
|
if (!state.catalog) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
const search = dom.searchInput.value.trim().toLowerCase();
|
||||||
|
const category = dom.categoryFilter.value;
|
||||||
|
const spawnState = dom.spawnFilter.value;
|
||||||
|
|
||||||
|
return state.catalog.items.filter((item) => {
|
||||||
|
const entry = getProfileEntry(item.item);
|
||||||
|
if (!entry) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
if (category !== "all" && item.category !== category) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
if (spawnState === "enabled" && !entry.enabled) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
if (spawnState === "disabled" && entry.enabled) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
if (!search) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
item.item.toLowerCase().includes(search) ||
|
||||||
|
item.shortId.toLowerCase().includes(search)
|
||||||
|
);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderItemTable() {
|
||||||
|
const filteredItems = getFilteredItems();
|
||||||
|
dom.itemTableBody.innerHTML = "";
|
||||||
|
|
||||||
|
for (const item of filteredItems) {
|
||||||
|
const entry = getProfileEntry(item.item);
|
||||||
|
const row = document.createElement("tr");
|
||||||
|
if (state.selectedItem === item.item) {
|
||||||
|
row.classList.add("selected");
|
||||||
|
}
|
||||||
|
|
||||||
|
const spawnTd = document.createElement("td");
|
||||||
|
const spawnCheck = document.createElement("input");
|
||||||
|
spawnCheck.type = "checkbox";
|
||||||
|
spawnCheck.checked = entry.enabled;
|
||||||
|
spawnCheck.addEventListener("click", (event) => event.stopPropagation());
|
||||||
|
spawnCheck.addEventListener("change", () => {
|
||||||
|
updateProfileEntry(item.item, (target) => {
|
||||||
|
target.enabled = spawnCheck.checked;
|
||||||
|
});
|
||||||
|
renderItemTable();
|
||||||
|
renderSelectedDetails();
|
||||||
|
});
|
||||||
|
spawnTd.appendChild(spawnCheck);
|
||||||
|
|
||||||
|
const itemTd = document.createElement("td");
|
||||||
|
itemTd.textContent = item.shortId;
|
||||||
|
|
||||||
|
const categoryTd = document.createElement("td");
|
||||||
|
categoryTd.textContent = item.category;
|
||||||
|
|
||||||
|
const listsTd = document.createElement("td");
|
||||||
|
listsTd.textContent = String(Object.keys(entry.placements).length);
|
||||||
|
|
||||||
|
row.appendChild(spawnTd);
|
||||||
|
row.appendChild(itemTd);
|
||||||
|
row.appendChild(categoryTd);
|
||||||
|
row.appendChild(listsTd);
|
||||||
|
|
||||||
|
row.addEventListener("click", () => {
|
||||||
|
state.selectedItem = item.item;
|
||||||
|
renderItemTable();
|
||||||
|
renderSelectedDetails();
|
||||||
|
});
|
||||||
|
|
||||||
|
dom.itemTableBody.appendChild(row);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderPlacementsTable(item, entry, container) {
|
||||||
|
const table = document.createElement("table");
|
||||||
|
table.className = "placements-table";
|
||||||
|
const thead = document.createElement("thead");
|
||||||
|
thead.innerHTML = "<tr><th>List</th><th>Weight</th><th></th></tr>";
|
||||||
|
table.appendChild(thead);
|
||||||
|
|
||||||
|
const tbody = document.createElement("tbody");
|
||||||
|
const placementNames = Object.keys(entry.placements).sort();
|
||||||
|
|
||||||
|
for (const listName of placementNames) {
|
||||||
|
const weight = entry.placements[listName];
|
||||||
|
const tr = document.createElement("tr");
|
||||||
|
|
||||||
|
const listTd = document.createElement("td");
|
||||||
|
listTd.textContent = listName;
|
||||||
|
|
||||||
|
const weightTd = document.createElement("td");
|
||||||
|
const weightInput = document.createElement("input");
|
||||||
|
weightInput.type = "number";
|
||||||
|
weightInput.min = "0";
|
||||||
|
weightInput.step = "0.001";
|
||||||
|
weightInput.value = String(weight);
|
||||||
|
weightInput.addEventListener("change", () => {
|
||||||
|
const next = safeNumber(weightInput.value, 0);
|
||||||
|
if (next <= 0) {
|
||||||
|
delete entry.placements[listName];
|
||||||
|
} else {
|
||||||
|
entry.placements[listName] = Number(next.toFixed(6));
|
||||||
|
}
|
||||||
|
renderItemTable();
|
||||||
|
renderSelectedDetails();
|
||||||
|
});
|
||||||
|
weightTd.appendChild(weightInput);
|
||||||
|
|
||||||
|
const actionTd = document.createElement("td");
|
||||||
|
const removeBtn = document.createElement("button");
|
||||||
|
removeBtn.type = "button";
|
||||||
|
removeBtn.className = "small-btn remove";
|
||||||
|
removeBtn.textContent = "Remove";
|
||||||
|
removeBtn.addEventListener("click", () => {
|
||||||
|
delete entry.placements[listName];
|
||||||
|
renderItemTable();
|
||||||
|
renderSelectedDetails();
|
||||||
|
});
|
||||||
|
actionTd.appendChild(removeBtn);
|
||||||
|
|
||||||
|
tr.appendChild(listTd);
|
||||||
|
tr.appendChild(weightTd);
|
||||||
|
tr.appendChild(actionTd);
|
||||||
|
tbody.appendChild(tr);
|
||||||
|
}
|
||||||
|
|
||||||
|
table.appendChild(tbody);
|
||||||
|
container.appendChild(table);
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderSelectedDetails() {
|
||||||
|
const selected = state.selectedItem;
|
||||||
|
dom.selectedDetails.innerHTML = "";
|
||||||
|
|
||||||
|
if (!state.catalog || !selected) {
|
||||||
|
dom.selectedDetails.textContent = "Select an item to edit placements and spawn rate.";
|
||||||
|
dom.selectedDetails.className = "details-empty";
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const item = state.catalog.items.find((it) => it.item === selected);
|
||||||
|
const entry = getProfileEntry(selected);
|
||||||
|
if (!item || !entry) {
|
||||||
|
dom.selectedDetails.textContent = "Selected item not found.";
|
||||||
|
dom.selectedDetails.className = "details-empty";
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
dom.selectedDetails.className = "details-body";
|
||||||
|
|
||||||
|
const itemHeader = document.createElement("div");
|
||||||
|
itemHeader.className = "item-header";
|
||||||
|
itemHeader.innerHTML = `
|
||||||
|
<div>
|
||||||
|
<h3 class="item-title">${item.item}</h3>
|
||||||
|
<span class="badge">${item.category}</span>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
dom.selectedDetails.appendChild(itemHeader);
|
||||||
|
|
||||||
|
const enabledRow = document.createElement("div");
|
||||||
|
enabledRow.className = "inline-row";
|
||||||
|
const enabledInput = document.createElement("input");
|
||||||
|
enabledInput.type = "checkbox";
|
||||||
|
enabledInput.checked = entry.enabled;
|
||||||
|
enabledInput.addEventListener("change", () => {
|
||||||
|
entry.enabled = enabledInput.checked;
|
||||||
|
renderItemTable();
|
||||||
|
});
|
||||||
|
|
||||||
|
const enabledLabel = document.createElement("label");
|
||||||
|
enabledLabel.textContent = "Spawn enabled";
|
||||||
|
enabledLabel.prepend(enabledInput);
|
||||||
|
enabledLabel.style.display = "inline-flex";
|
||||||
|
enabledLabel.style.alignItems = "center";
|
||||||
|
enabledLabel.style.gap = "0.35rem";
|
||||||
|
enabledRow.appendChild(enabledLabel);
|
||||||
|
dom.selectedDetails.appendChild(enabledRow);
|
||||||
|
|
||||||
|
const placementsLabel = document.createElement("p");
|
||||||
|
placementsLabel.textContent = "Placements (distribution list + spawn rate weight):";
|
||||||
|
placementsLabel.style.margin = "0 0 0.4rem";
|
||||||
|
dom.selectedDetails.appendChild(placementsLabel);
|
||||||
|
|
||||||
|
renderPlacementsTable(item, entry, dom.selectedDetails);
|
||||||
|
|
||||||
|
const addRow = document.createElement("div");
|
||||||
|
addRow.className = "inline-row";
|
||||||
|
|
||||||
|
const listSelect = document.createElement("select");
|
||||||
|
const usedLists = new Set(Object.keys(entry.placements));
|
||||||
|
const availableLists = state.catalog.lists.filter((listName) => !usedLists.has(listName));
|
||||||
|
for (const listName of availableLists) {
|
||||||
|
const option = document.createElement("option");
|
||||||
|
option.value = listName;
|
||||||
|
option.textContent = listName;
|
||||||
|
listSelect.appendChild(option);
|
||||||
|
}
|
||||||
|
|
||||||
|
const customInput = document.createElement("input");
|
||||||
|
customInput.type = "text";
|
||||||
|
customInput.placeholder = "or custom list name";
|
||||||
|
|
||||||
|
const weightInput = document.createElement("input");
|
||||||
|
weightInput.type = "number";
|
||||||
|
weightInput.min = "0";
|
||||||
|
weightInput.step = "0.001";
|
||||||
|
weightInput.value = "1";
|
||||||
|
|
||||||
|
const addButton = document.createElement("button");
|
||||||
|
addButton.type = "button";
|
||||||
|
addButton.className = "small-btn";
|
||||||
|
addButton.textContent = "Add Placement";
|
||||||
|
addButton.addEventListener("click", () => {
|
||||||
|
const custom = customInput.value.trim();
|
||||||
|
const selectedList = custom || listSelect.value;
|
||||||
|
const weight = safeNumber(weightInput.value, 0);
|
||||||
|
if (!selectedList || weight <= 0) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
entry.placements[selectedList] = Number(weight.toFixed(6));
|
||||||
|
if (!state.catalog.lists.includes(selectedList)) {
|
||||||
|
state.catalog.lists.push(selectedList);
|
||||||
|
state.catalog.lists.sort();
|
||||||
|
}
|
||||||
|
renderItemTable();
|
||||||
|
renderSelectedDetails();
|
||||||
|
});
|
||||||
|
|
||||||
|
addRow.appendChild(listSelect);
|
||||||
|
addRow.appendChild(customInput);
|
||||||
|
addRow.appendChild(weightInput);
|
||||||
|
addRow.appendChild(addButton);
|
||||||
|
dom.selectedDetails.appendChild(addRow);
|
||||||
|
|
||||||
|
const resetRow = document.createElement("div");
|
||||||
|
resetRow.className = "inline-row";
|
||||||
|
|
||||||
|
const restoreButton = document.createElement("button");
|
||||||
|
restoreButton.type = "button";
|
||||||
|
restoreButton.className = "small-btn";
|
||||||
|
restoreButton.textContent = "Restore Catalog Placements";
|
||||||
|
restoreButton.addEventListener("click", () => {
|
||||||
|
entry.enabled = item.defaultEnabled;
|
||||||
|
entry.placements = { ...item.defaultPlacements };
|
||||||
|
renderItemTable();
|
||||||
|
renderSelectedDetails();
|
||||||
|
});
|
||||||
|
|
||||||
|
const clearButton = document.createElement("button");
|
||||||
|
clearButton.type = "button";
|
||||||
|
clearButton.className = "small-btn remove";
|
||||||
|
clearButton.textContent = "Clear Placements";
|
||||||
|
clearButton.addEventListener("click", () => {
|
||||||
|
entry.placements = {};
|
||||||
|
renderItemTable();
|
||||||
|
renderSelectedDetails();
|
||||||
|
});
|
||||||
|
|
||||||
|
resetRow.appendChild(restoreButton);
|
||||||
|
resetRow.appendChild(clearButton);
|
||||||
|
dom.selectedDetails.appendChild(resetRow);
|
||||||
|
|
||||||
|
const meta = document.createElement("p");
|
||||||
|
meta.className = "meta";
|
||||||
|
meta.innerHTML = `
|
||||||
|
Sandbox weight controls: ${item.spawnControlKeys.length ? item.spawnControlKeys.join(", ") : "none"}<br>
|
||||||
|
Catalog list count: ${Object.keys(item.defaultPlacements).length}
|
||||||
|
`;
|
||||||
|
dom.selectedDetails.appendChild(meta);
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildExportProfile() {
|
||||||
|
const entries = Object.keys(state.profileByItem)
|
||||||
|
.sort()
|
||||||
|
.map((itemType) => {
|
||||||
|
const entry = state.profileByItem[itemType];
|
||||||
|
const placements = Object.keys(entry.placements)
|
||||||
|
.sort()
|
||||||
|
.map((listName) => ({
|
||||||
|
list: listName,
|
||||||
|
weight: Number(entry.placements[listName]),
|
||||||
|
}));
|
||||||
|
return {
|
||||||
|
item: itemType,
|
||||||
|
category: entry.category,
|
||||||
|
enabled: entry.enabled,
|
||||||
|
placements,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
formatVersion: 1,
|
||||||
|
generatedAt: new Date().toISOString(),
|
||||||
|
sourceCatalog: {
|
||||||
|
generatedAt: state.catalog ? state.catalog.generatedAt : null,
|
||||||
|
source: state.catalog ? state.catalog.source : {},
|
||||||
|
},
|
||||||
|
entries,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function downloadTextFile(fileName, content) {
|
||||||
|
const blob = new Blob([content], { type: "application/json" });
|
||||||
|
const url = URL.createObjectURL(blob);
|
||||||
|
const anchor = document.createElement("a");
|
||||||
|
anchor.href = url;
|
||||||
|
anchor.download = fileName;
|
||||||
|
anchor.click();
|
||||||
|
URL.revokeObjectURL(url);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function onCatalogFileSelected() {
|
||||||
|
const file = dom.catalogFile.files[0];
|
||||||
|
if (!file) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const text = await readFileText(file);
|
||||||
|
const rawCatalog = JSON.parse(text);
|
||||||
|
state.catalog = normalizeCatalog(rawCatalog);
|
||||||
|
initializeProfileFromCatalog();
|
||||||
|
state.selectedItem = state.catalog.items.length ? state.catalog.items[0].item : null;
|
||||||
|
setStatus(
|
||||||
|
`Catalog loaded (${state.catalog.items.length} items, ${state.catalog.lists.length} lists).`
|
||||||
|
);
|
||||||
|
renderItemTable();
|
||||||
|
renderSelectedDetails();
|
||||||
|
} catch (error) {
|
||||||
|
setStatus(`Catalog load failed: ${error.message}`);
|
||||||
|
} finally {
|
||||||
|
dom.catalogFile.value = "";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function onProfileFileSelected() {
|
||||||
|
if (!state.catalog) {
|
||||||
|
setStatus("Load a catalog first.");
|
||||||
|
dom.profileFile.value = "";
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const file = dom.profileFile.files[0];
|
||||||
|
if (!file) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const text = await readFileText(file);
|
||||||
|
const raw = JSON.parse(text);
|
||||||
|
if (!Array.isArray(raw.entries)) {
|
||||||
|
throw new Error("Profile must contain an entries array.");
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const row of raw.entries) {
|
||||||
|
const itemType = normalizeItemType(row.item);
|
||||||
|
if (!itemType || !state.profileByItem[itemType]) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const entry = state.profileByItem[itemType];
|
||||||
|
entry.enabled = row.enabled !== false;
|
||||||
|
entry.placements = {};
|
||||||
|
if (Array.isArray(row.placements)) {
|
||||||
|
for (const placement of row.placements) {
|
||||||
|
const listName =
|
||||||
|
placement && typeof placement.list === "string" ? placement.list.trim() : "";
|
||||||
|
const weight = safeNumber(placement && placement.weight, 0);
|
||||||
|
if (!listName || weight <= 0) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
entry.placements[listName] = Number(weight.toFixed(6));
|
||||||
|
if (!state.catalog.lists.includes(listName)) {
|
||||||
|
state.catalog.lists.push(listName);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
state.catalog.lists.sort();
|
||||||
|
setStatus("Profile loaded and applied to current catalog.");
|
||||||
|
renderItemTable();
|
||||||
|
renderSelectedDetails();
|
||||||
|
} catch (error) {
|
||||||
|
setStatus(`Profile load failed: ${error.message}`);
|
||||||
|
} finally {
|
||||||
|
dom.profileFile.value = "";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function onExportProfile() {
|
||||||
|
if (!state.catalog) {
|
||||||
|
setStatus("Load a catalog before exporting.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const payload = buildExportProfile();
|
||||||
|
downloadTextFile("of-spawn-profile.json", `${JSON.stringify(payload, null, 2)}\n`);
|
||||||
|
setStatus("Profile exported.");
|
||||||
|
}
|
||||||
|
|
||||||
|
function onResetSelected() {
|
||||||
|
if (!state.catalog || !state.selectedItem) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const item = state.catalog.items.find((it) => it.item === state.selectedItem);
|
||||||
|
const entry = getProfileEntry(state.selectedItem);
|
||||||
|
if (!item || !entry) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
entry.enabled = item.defaultEnabled;
|
||||||
|
entry.placements = { ...item.defaultPlacements };
|
||||||
|
renderItemTable();
|
||||||
|
renderSelectedDetails();
|
||||||
|
setStatus(`Reset ${item.shortId} to catalog defaults.`);
|
||||||
|
}
|
||||||
|
|
||||||
|
dom.catalogFile.addEventListener("change", onCatalogFileSelected);
|
||||||
|
dom.profileFile.addEventListener("change", onProfileFileSelected);
|
||||||
|
dom.exportProfile.addEventListener("click", onExportProfile);
|
||||||
|
dom.searchInput.addEventListener("input", renderItemTable);
|
||||||
|
dom.categoryFilter.addEventListener("change", renderItemTable);
|
||||||
|
dom.spawnFilter.addEventListener("change", renderItemTable);
|
||||||
|
dom.resetSelected.addEventListener("click", onResetSelected);
|
||||||
77
index.html
Normal file
77
index.html
Normal file
@@ -0,0 +1,77 @@
|
|||||||
|
<!doctype html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||||
|
<title>Opinionated Firearms Spawn List Builder</title>
|
||||||
|
<link rel="stylesheet" href="./styles.css">
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<header class="topbar">
|
||||||
|
<div>
|
||||||
|
<h1>Opinionated Firearms Spawn List Builder</h1>
|
||||||
|
<p>Edit firearm/attachment spawn enablement, placement lists, and spawn rates.</p>
|
||||||
|
</div>
|
||||||
|
<div class="actions">
|
||||||
|
<label class="file-button">
|
||||||
|
Load Catalog JSON
|
||||||
|
<input id="catalogFile" type="file" accept=".json,application/json">
|
||||||
|
</label>
|
||||||
|
<label class="file-button secondary">
|
||||||
|
Load Profile JSON
|
||||||
|
<input id="profileFile" type="file" accept=".json,application/json">
|
||||||
|
</label>
|
||||||
|
<button id="exportProfile" type="button">Export Profile JSON</button>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<section class="status">
|
||||||
|
<span id="statusText">Load an extracted catalog JSON to begin.</span>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<main class="layout">
|
||||||
|
<section class="panel">
|
||||||
|
<div class="panel-head">
|
||||||
|
<h2>Items</h2>
|
||||||
|
<div class="filters">
|
||||||
|
<input id="searchInput" type="search" placeholder="Search item ID...">
|
||||||
|
<select id="categoryFilter">
|
||||||
|
<option value="all">All categories</option>
|
||||||
|
<option value="firearm">Firearms</option>
|
||||||
|
<option value="attachment">Attachments</option>
|
||||||
|
<option value="unknown">Unknown</option>
|
||||||
|
</select>
|
||||||
|
<select id="spawnFilter">
|
||||||
|
<option value="all">All spawn states</option>
|
||||||
|
<option value="enabled">Spawn enabled</option>
|
||||||
|
<option value="disabled">Spawn disabled</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="table-wrap">
|
||||||
|
<table>
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>Spawn</th>
|
||||||
|
<th>Item</th>
|
||||||
|
<th>Category</th>
|
||||||
|
<th>Spawn Loacation</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody id="itemTableBody"></tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="panel details-panel">
|
||||||
|
<div class="panel-head">
|
||||||
|
<h2>Selected Item</h2>
|
||||||
|
<button id="resetSelected" type="button">Reset to Catalog</button>
|
||||||
|
</div>
|
||||||
|
<div id="selectedDetails" class="details-empty">Select an item to edit placements and spawn rate.</div>
|
||||||
|
</section>
|
||||||
|
</main>
|
||||||
|
|
||||||
|
<script src="./app.js"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
1591
jellycleanarr_data_dump.txt
Normal file
1591
jellycleanarr_data_dump.txt
Normal file
File diff suppressed because it is too large
Load Diff
49
pyproject.toml
Normal file
49
pyproject.toml
Normal file
@@ -0,0 +1,49 @@
|
|||||||
|
[tool.poetry]
|
||||||
|
name = "jellycleanarr"
|
||||||
|
version = "0.1.0"
|
||||||
|
description = "A TUI that allows you to see and delete old media from Jellyfin"
|
||||||
|
authors = ["Hudson Riggs"]
|
||||||
|
readme = "README.md"
|
||||||
|
packages = [{include = "jellycleanarr", from = "src"}]
|
||||||
|
|
||||||
|
[tool.poetry.dependencies]
|
||||||
|
python = "^3.10"
|
||||||
|
textual = "^0.83.0"
|
||||||
|
httpx = "^0.27.0"
|
||||||
|
pydantic = "^2.9.0"
|
||||||
|
pydantic-settings = "^2.6.0"
|
||||||
|
python-dotenv = "^1.0.0"
|
||||||
|
|
||||||
|
[tool.poetry.group.dev.dependencies]
|
||||||
|
pytest = "^8.3.0"
|
||||||
|
pytest-asyncio = "^0.24.0"
|
||||||
|
pytest-cov = "^6.0.0"
|
||||||
|
black = "^24.10.0"
|
||||||
|
ruff = "^0.7.0"
|
||||||
|
mypy = "^1.13.0"
|
||||||
|
|
||||||
|
[tool.poetry.scripts]
|
||||||
|
jellycleanarr = "jellycleanarr.main:main"
|
||||||
|
|
||||||
|
[build-system]
|
||||||
|
requires = ["poetry-core"]
|
||||||
|
build-backend = "poetry.core.masonry.api"
|
||||||
|
|
||||||
|
[tool.black]
|
||||||
|
line-length = 88
|
||||||
|
target-version = ['py310']
|
||||||
|
|
||||||
|
[tool.ruff]
|
||||||
|
line-length = 88
|
||||||
|
target-version = "py310"
|
||||||
|
|
||||||
|
[tool.mypy]
|
||||||
|
python_version = "3.10"
|
||||||
|
warn_return_any = true
|
||||||
|
warn_unused_configs = true
|
||||||
|
disallow_untyped_defs = true
|
||||||
|
|
||||||
|
[tool.pytest.ini_options]
|
||||||
|
asyncio_mode = "auto"
|
||||||
|
asyncio_default_fixture_loop_scope = "function"
|
||||||
|
testpaths = ["tests"]
|
||||||
14
run_with_logging.py
Normal file
14
run_with_logging.py
Normal file
@@ -0,0 +1,14 @@
|
|||||||
|
"""Run the app with logging enabled."""
|
||||||
|
|
||||||
|
import sys
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
# Add src to path
|
||||||
|
sys.path.insert(0, str(Path(__file__).parent / "src"))
|
||||||
|
|
||||||
|
from jellycleanarr.ui.app import JellycleanApp
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
app = JellycleanApp()
|
||||||
|
# Run with logging (logs go to textual.log by default)
|
||||||
|
app.run(log="textual_debug.log")
|
||||||
0
src/jellycleanarr/__init__.py
Normal file
0
src/jellycleanarr/__init__.py
Normal file
7
src/jellycleanarr/__main__.py
Normal file
7
src/jellycleanarr/__main__.py
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
"""Allow running the package as a module with python -m jellycleanarr."""
|
||||||
|
|
||||||
|
import sys
|
||||||
|
from .main import main
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
sys.exit(main())
|
||||||
0
src/jellycleanarr/api/__init__.py
Normal file
0
src/jellycleanarr/api/__init__.py
Normal file
171
src/jellycleanarr/api/client.py
Normal file
171
src/jellycleanarr/api/client.py
Normal file
@@ -0,0 +1,171 @@
|
|||||||
|
"""Jellyfin API client for communicating with Jellyfin server."""
|
||||||
|
|
||||||
|
import httpx
|
||||||
|
from typing import List, Dict, Any, Optional
|
||||||
|
from ..utils.exceptions import (
|
||||||
|
JellyfinConnectionError,
|
||||||
|
JellyfinAuthError,
|
||||||
|
JellyfinAPIError,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class JellyfinClient:
|
||||||
|
"""Client for interacting with Jellyfin API."""
|
||||||
|
|
||||||
|
def __init__(self, base_url: str, api_key: str):
|
||||||
|
"""
|
||||||
|
Initialize Jellyfin client.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
base_url: Base URL of Jellyfin server (e.g., https://jellyfin.example.com)
|
||||||
|
api_key: API key for authentication
|
||||||
|
"""
|
||||||
|
self.base_url = base_url.rstrip("/")
|
||||||
|
self.api_key = api_key
|
||||||
|
self.headers = {
|
||||||
|
"Authorization": f"MediaBrowser Token={api_key}",
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
}
|
||||||
|
self.client = httpx.AsyncClient(timeout=30.0)
|
||||||
|
|
||||||
|
async def test_connection(self) -> bool:
|
||||||
|
"""
|
||||||
|
Test connection to Jellyfin server.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
True if connection successful
|
||||||
|
|
||||||
|
Raises:
|
||||||
|
JellyfinConnectionError: If cannot connect to server
|
||||||
|
JellyfinAuthError: If authentication fails
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
response = await self.client.get(
|
||||||
|
f"{self.base_url}/System/Info", headers=self.headers
|
||||||
|
)
|
||||||
|
if response.status_code == 401:
|
||||||
|
raise JellyfinAuthError("Authentication failed. Check your API key.")
|
||||||
|
response.raise_for_status()
|
||||||
|
return True
|
||||||
|
except httpx.RequestError as e:
|
||||||
|
raise JellyfinConnectionError(f"Cannot connect to {self.base_url}: {e}")
|
||||||
|
except httpx.HTTPStatusError as e:
|
||||||
|
if e.response.status_code == 401:
|
||||||
|
raise JellyfinAuthError("Authentication failed. Check your API key.")
|
||||||
|
raise JellyfinAPIError(f"API error: {e}")
|
||||||
|
|
||||||
|
async def get_users(self) -> List[Dict[str, Any]]:
|
||||||
|
"""
|
||||||
|
Get all Jellyfin users.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
List of user dictionaries
|
||||||
|
|
||||||
|
Raises:
|
||||||
|
JellyfinAPIError: If API request fails
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
response = await self.client.get(
|
||||||
|
f"{self.base_url}/Users", headers=self.headers
|
||||||
|
)
|
||||||
|
response.raise_for_status()
|
||||||
|
return response.json()
|
||||||
|
except httpx.HTTPStatusError as e:
|
||||||
|
raise JellyfinAPIError(f"Failed to get users: {e}")
|
||||||
|
|
||||||
|
async def get_libraries(self) -> List[Dict[str, Any]]:
|
||||||
|
"""
|
||||||
|
Get all media libraries.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
List of library dictionaries
|
||||||
|
|
||||||
|
Raises:
|
||||||
|
JellyfinAPIError: If API request fails
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
response = await self.client.get(
|
||||||
|
f"{self.base_url}/Library/MediaFolders", headers=self.headers
|
||||||
|
)
|
||||||
|
response.raise_for_status()
|
||||||
|
data = response.json()
|
||||||
|
return data.get("Items", [])
|
||||||
|
except httpx.HTTPStatusError as e:
|
||||||
|
raise JellyfinAPIError(f"Failed to get libraries: {e}")
|
||||||
|
|
||||||
|
async def query_playback_stats(
|
||||||
|
self, custom_query: str, replace_user_id: bool = True
|
||||||
|
) -> List[Dict[str, Any]]:
|
||||||
|
"""
|
||||||
|
Execute custom SQL query against playback reporting database.
|
||||||
|
|
||||||
|
The Playback Reporting Plugin stores data in SQLite with schema:
|
||||||
|
- PlaybackActivity table with columns:
|
||||||
|
- RowId, DateCreated, UserId, ItemId, ItemName, ItemType,
|
||||||
|
PlaybackMethod, ClientName, DeviceName, PlayDuration
|
||||||
|
|
||||||
|
Args:
|
||||||
|
custom_query: SQL query to execute
|
||||||
|
replace_user_id: Whether to replace user IDs with usernames
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
List of result rows as dictionaries
|
||||||
|
|
||||||
|
Raises:
|
||||||
|
JellyfinAPIError: If API request fails
|
||||||
|
"""
|
||||||
|
payload = {
|
||||||
|
"CustomQueryString": custom_query,
|
||||||
|
"ReplaceUserId": replace_user_id,
|
||||||
|
}
|
||||||
|
try:
|
||||||
|
response = await self.client.post(
|
||||||
|
f"{self.base_url}/user_usage_stats/submit_custom_query",
|
||||||
|
headers=self.headers,
|
||||||
|
json=payload,
|
||||||
|
)
|
||||||
|
response.raise_for_status()
|
||||||
|
data = response.json()
|
||||||
|
|
||||||
|
# The API returns columns and results separately
|
||||||
|
# Note: API has typo - returns "colums" not "columns"
|
||||||
|
columns = data.get("colums", [])
|
||||||
|
results = data.get("results", [])
|
||||||
|
|
||||||
|
# Convert list of lists to list of dictionaries
|
||||||
|
dict_results = []
|
||||||
|
for row in results:
|
||||||
|
row_dict = {}
|
||||||
|
for i, col_name in enumerate(columns):
|
||||||
|
row_dict[col_name] = row[i] if i < len(row) else None
|
||||||
|
dict_results.append(row_dict)
|
||||||
|
|
||||||
|
return dict_results
|
||||||
|
except httpx.HTTPStatusError as e:
|
||||||
|
raise JellyfinAPIError(f"Failed to query playback stats: {e}")
|
||||||
|
|
||||||
|
async def get_item_details(self, item_id: str) -> Dict[str, Any]:
|
||||||
|
"""
|
||||||
|
Get details for a specific media item.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
item_id: Jellyfin item ID
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Item details dictionary
|
||||||
|
|
||||||
|
Raises:
|
||||||
|
JellyfinAPIError: If API request fails
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
response = await self.client.get(
|
||||||
|
f"{self.base_url}/Items/{item_id}", headers=self.headers
|
||||||
|
)
|
||||||
|
response.raise_for_status()
|
||||||
|
return response.json()
|
||||||
|
except httpx.HTTPStatusError as e:
|
||||||
|
raise JellyfinAPIError(f"Failed to get item details for {item_id}: {e}")
|
||||||
|
|
||||||
|
async def close(self) -> None:
|
||||||
|
"""Close the HTTP client."""
|
||||||
|
await self.client.aclose()
|
||||||
21
src/jellycleanarr/config.py
Normal file
21
src/jellycleanarr/config.py
Normal file
@@ -0,0 +1,21 @@
|
|||||||
|
"""Configuration management for Jellycleanarr using pydantic-settings."""
|
||||||
|
|
||||||
|
from typing import Optional
|
||||||
|
from pydantic_settings import BaseSettings, SettingsConfigDict
|
||||||
|
|
||||||
|
|
||||||
|
class Settings(BaseSettings):
|
||||||
|
"""Application settings loaded from environment variables."""
|
||||||
|
|
||||||
|
jellyfin_address: str
|
||||||
|
jellyfin_api_key: str
|
||||||
|
jellyfin_username: Optional[str] = None
|
||||||
|
cache_ttl: int = 300 # Cache time-to-live in seconds
|
||||||
|
|
||||||
|
model_config = SettingsConfigDict(
|
||||||
|
env_file=".env", env_file_encoding="utf-8", case_sensitive=False
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# Global settings instance
|
||||||
|
settings = Settings()
|
||||||
60
src/jellycleanarr/main.py
Normal file
60
src/jellycleanarr/main.py
Normal file
@@ -0,0 +1,60 @@
|
|||||||
|
"""Main application entry point for Jellycleanarr."""
|
||||||
|
|
||||||
|
import sys
|
||||||
|
from .ui.app import JellycleanApp
|
||||||
|
from .utils.exceptions import JellyfinError
|
||||||
|
from pydantic import ValidationError
|
||||||
|
|
||||||
|
|
||||||
|
def main() -> int:
|
||||||
|
"""
|
||||||
|
Main entry point for the application.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Exit code (0 for success, 1 for error)
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
# Validate configuration on startup
|
||||||
|
from .config import settings
|
||||||
|
|
||||||
|
if not settings.jellyfin_address:
|
||||||
|
print("Error: JELLYFIN_ADDRESS not set in .env file")
|
||||||
|
print(
|
||||||
|
"Please create a .env file with JELLYFIN_ADDRESS and JELLYFIN_API_KEY"
|
||||||
|
)
|
||||||
|
return 1
|
||||||
|
|
||||||
|
if not settings.jellyfin_api_key:
|
||||||
|
print("Error: JELLYFIN_API_KEY not set in .env file")
|
||||||
|
print(
|
||||||
|
"Please create a .env file with JELLYFIN_ADDRESS and JELLYFIN_API_KEY"
|
||||||
|
)
|
||||||
|
return 1
|
||||||
|
|
||||||
|
# Run the app
|
||||||
|
app = JellycleanApp()
|
||||||
|
app.run()
|
||||||
|
return 0
|
||||||
|
|
||||||
|
except ValidationError as e:
|
||||||
|
print(f"Configuration Error: {e}")
|
||||||
|
print("\nPlease create a .env file with the following variables:")
|
||||||
|
print(" JELLYFIN_ADDRESS=https://your-jellyfin-server.com")
|
||||||
|
print(" JELLYFIN_API_KEY=your_api_key_here")
|
||||||
|
return 1
|
||||||
|
except JellyfinError as e:
|
||||||
|
print(f"Jellyfin Error: {e}")
|
||||||
|
return 1
|
||||||
|
except KeyboardInterrupt:
|
||||||
|
print("\nInterrupted by user")
|
||||||
|
return 0
|
||||||
|
except Exception as e:
|
||||||
|
print(f"Unexpected error: {e}")
|
||||||
|
import traceback
|
||||||
|
|
||||||
|
traceback.print_exc()
|
||||||
|
return 1
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
sys.exit(main())
|
||||||
0
src/jellycleanarr/models/__init__.py
Normal file
0
src/jellycleanarr/models/__init__.py
Normal file
46
src/jellycleanarr/models/media.py
Normal file
46
src/jellycleanarr/models/media.py
Normal file
@@ -0,0 +1,46 @@
|
|||||||
|
"""Media data models for series, movies, and libraries."""
|
||||||
|
|
||||||
|
from typing import Optional, Literal
|
||||||
|
from pydantic import BaseModel
|
||||||
|
|
||||||
|
|
||||||
|
class MediaItem(BaseModel):
|
||||||
|
"""Media item model."""
|
||||||
|
|
||||||
|
item_id: str
|
||||||
|
name: str
|
||||||
|
type: Literal["Series", "Movie", "Episode", "Season"]
|
||||||
|
parent_id: Optional[str] = None
|
||||||
|
parent_name: Optional[str] = None
|
||||||
|
|
||||||
|
|
||||||
|
class SeriesPlaybackStats(BaseModel):
|
||||||
|
"""Series playback statistics."""
|
||||||
|
|
||||||
|
series_id: str
|
||||||
|
series_name: str
|
||||||
|
total_plays: int
|
||||||
|
total_duration_seconds: int
|
||||||
|
unique_users: int
|
||||||
|
episode_count: int = 0
|
||||||
|
|
||||||
|
@property
|
||||||
|
def total_duration_hours(self) -> float:
|
||||||
|
"""Get total duration in hours."""
|
||||||
|
return self.total_duration_seconds / 3600
|
||||||
|
|
||||||
|
|
||||||
|
class LibraryStats(BaseModel):
|
||||||
|
"""Library playback statistics."""
|
||||||
|
|
||||||
|
library_id: str
|
||||||
|
library_name: str
|
||||||
|
total_plays: int
|
||||||
|
total_duration_seconds: int
|
||||||
|
unique_users: int
|
||||||
|
item_count: int = 0
|
||||||
|
|
||||||
|
@property
|
||||||
|
def total_duration_hours(self) -> float:
|
||||||
|
"""Get total duration in hours."""
|
||||||
|
return self.total_duration_seconds / 3600
|
||||||
22
src/jellycleanarr/models/playback.py
Normal file
22
src/jellycleanarr/models/playback.py
Normal file
@@ -0,0 +1,22 @@
|
|||||||
|
"""Playback data models from Jellyfin Playback Reporting Plugin."""
|
||||||
|
|
||||||
|
from datetime import datetime
|
||||||
|
from typing import Optional
|
||||||
|
from pydantic import BaseModel, Field
|
||||||
|
|
||||||
|
|
||||||
|
class PlaybackActivity(BaseModel):
|
||||||
|
"""Raw playback record from Jellyfin PlaybackActivity table."""
|
||||||
|
|
||||||
|
row_id: int = Field(alias="RowId")
|
||||||
|
date_created: datetime = Field(alias="DateCreated")
|
||||||
|
user_id: str = Field(alias="UserId")
|
||||||
|
item_id: str = Field(alias="ItemId")
|
||||||
|
item_name: str = Field(alias="ItemName")
|
||||||
|
item_type: str = Field(alias="ItemType")
|
||||||
|
play_duration: int = Field(alias="PlayDuration") # seconds
|
||||||
|
playback_method: Optional[str] = Field(None, alias="PlaybackMethod")
|
||||||
|
client_name: Optional[str] = Field(None, alias="ClientName")
|
||||||
|
|
||||||
|
class Config:
|
||||||
|
populate_by_name = True
|
||||||
29
src/jellycleanarr/models/user.py
Normal file
29
src/jellycleanarr/models/user.py
Normal file
@@ -0,0 +1,29 @@
|
|||||||
|
"""User data models for Jellyfin users and their statistics."""
|
||||||
|
|
||||||
|
from datetime import datetime
|
||||||
|
from typing import Optional
|
||||||
|
from pydantic import BaseModel
|
||||||
|
|
||||||
|
|
||||||
|
class JellyfinUser(BaseModel):
|
||||||
|
"""Jellyfin user model."""
|
||||||
|
|
||||||
|
user_id: str
|
||||||
|
username: str
|
||||||
|
is_administrator: bool = False
|
||||||
|
|
||||||
|
|
||||||
|
class UserPlaybackStats(BaseModel):
|
||||||
|
"""User playback statistics aggregated from PlaybackActivity."""
|
||||||
|
|
||||||
|
user_id: str
|
||||||
|
username: str
|
||||||
|
total_plays: int
|
||||||
|
total_duration_seconds: int
|
||||||
|
unique_items_played: int
|
||||||
|
last_played: Optional[datetime] = None
|
||||||
|
|
||||||
|
@property
|
||||||
|
def total_duration_hours(self) -> float:
|
||||||
|
"""Get total duration in hours."""
|
||||||
|
return self.total_duration_seconds / 3600
|
||||||
0
src/jellycleanarr/services/__init__.py
Normal file
0
src/jellycleanarr/services/__init__.py
Normal file
190
src/jellycleanarr/services/stats_service.py
Normal file
190
src/jellycleanarr/services/stats_service.py
Normal file
@@ -0,0 +1,190 @@
|
|||||||
|
"""Statistics service for aggregating and processing playback data."""
|
||||||
|
|
||||||
|
from typing import List, Tuple, Dict
|
||||||
|
from ..api.client import JellyfinClient
|
||||||
|
from ..models.user import UserPlaybackStats
|
||||||
|
from ..models.media import SeriesPlaybackStats, LibraryStats
|
||||||
|
|
||||||
|
|
||||||
|
class StatsService:
|
||||||
|
"""Service for aggregating playback statistics."""
|
||||||
|
|
||||||
|
def __init__(self, client: JellyfinClient):
|
||||||
|
"""
|
||||||
|
Initialize stats service.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
client: Jellyfin API client
|
||||||
|
"""
|
||||||
|
self.client = client
|
||||||
|
|
||||||
|
async def get_user_stats(
|
||||||
|
self, limit: int = 50
|
||||||
|
) -> Tuple[List[UserPlaybackStats], List[UserPlaybackStats]]:
|
||||||
|
"""
|
||||||
|
Get user statistics sorted by most and least played.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
limit: Maximum number of results to return for each list
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Tuple of (most_played, least_played) user stats
|
||||||
|
"""
|
||||||
|
# Query playback data grouped by user
|
||||||
|
query = """
|
||||||
|
SELECT
|
||||||
|
UserId,
|
||||||
|
COUNT(*) as total_plays,
|
||||||
|
SUM(PlayDuration) as total_duration,
|
||||||
|
COUNT(DISTINCT ItemId) as unique_items,
|
||||||
|
MAX(DateCreated) as last_played
|
||||||
|
FROM PlaybackActivity
|
||||||
|
GROUP BY UserId
|
||||||
|
ORDER BY total_plays DESC
|
||||||
|
"""
|
||||||
|
|
||||||
|
results = await self.client.query_playback_stats(query, replace_user_id=False)
|
||||||
|
|
||||||
|
# Get user details and merge with stats
|
||||||
|
users = await self.client.get_users()
|
||||||
|
user_map = {u["Id"]: u["Name"] for u in users}
|
||||||
|
|
||||||
|
stats = []
|
||||||
|
for row in results:
|
||||||
|
user_id = row.get("UserId", "")
|
||||||
|
stats.append(
|
||||||
|
UserPlaybackStats(
|
||||||
|
user_id=user_id,
|
||||||
|
username=user_map.get(user_id, "Unknown"),
|
||||||
|
total_plays=int(row.get("total_plays", 0)),
|
||||||
|
total_duration_seconds=int(row.get("total_duration", 0)),
|
||||||
|
unique_items_played=int(row.get("unique_items", 0)),
|
||||||
|
last_played=row.get("last_played"),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
# Sort by total plays
|
||||||
|
stats_sorted = sorted(stats, key=lambda x: x.total_plays, reverse=True)
|
||||||
|
|
||||||
|
most_played = stats_sorted[:limit]
|
||||||
|
least_played = sorted(stats_sorted, key=lambda x: x.total_plays)[:limit]
|
||||||
|
|
||||||
|
return most_played, least_played
|
||||||
|
|
||||||
|
async def get_series_stats(
|
||||||
|
self, limit: int = 50
|
||||||
|
) -> Tuple[List[SeriesPlaybackStats], List[SeriesPlaybackStats]]:
|
||||||
|
"""
|
||||||
|
Get series statistics sorted by most and least played.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
limit: Maximum number of results to return for each list
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Tuple of (most_played, least_played) series stats
|
||||||
|
"""
|
||||||
|
# Query for series and episode playback
|
||||||
|
# Group by series (using ItemId for Series type, or grouping episodes by their series)
|
||||||
|
query = """
|
||||||
|
SELECT
|
||||||
|
ItemId,
|
||||||
|
ItemName,
|
||||||
|
ItemType,
|
||||||
|
COUNT(*) as total_plays,
|
||||||
|
SUM(PlayDuration) as total_duration,
|
||||||
|
COUNT(DISTINCT UserId) as unique_users
|
||||||
|
FROM PlaybackActivity
|
||||||
|
WHERE ItemType IN ('Series', 'Episode', 'Movie')
|
||||||
|
GROUP BY ItemId
|
||||||
|
ORDER BY total_plays DESC
|
||||||
|
"""
|
||||||
|
|
||||||
|
results = await self.client.query_playback_stats(query, replace_user_id=False)
|
||||||
|
|
||||||
|
stats = []
|
||||||
|
for row in results:
|
||||||
|
# Create stats for each item (series or movie)
|
||||||
|
item_id = row.get("ItemId", "")
|
||||||
|
item_name = row.get("ItemName", "Unknown")
|
||||||
|
|
||||||
|
stats.append(
|
||||||
|
SeriesPlaybackStats(
|
||||||
|
series_id=item_id,
|
||||||
|
series_name=item_name,
|
||||||
|
total_plays=int(row.get("total_plays", 0)),
|
||||||
|
total_duration_seconds=int(row.get("total_duration", 0)),
|
||||||
|
unique_users=int(row.get("unique_users", 0)),
|
||||||
|
episode_count=0, # We'll update this if needed
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
# Sort by total plays
|
||||||
|
stats_sorted = sorted(stats, key=lambda x: x.total_plays, reverse=True)
|
||||||
|
|
||||||
|
most_played = stats_sorted[:limit]
|
||||||
|
least_played = sorted(stats_sorted, key=lambda x: x.total_plays)[:limit]
|
||||||
|
|
||||||
|
return most_played, least_played
|
||||||
|
|
||||||
|
async def get_library_stats(
|
||||||
|
self, limit: int = 50
|
||||||
|
) -> Tuple[List[LibraryStats], List[LibraryStats]]:
|
||||||
|
"""
|
||||||
|
Get library statistics sorted by most and least played.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
limit: Maximum number of results to return for each list
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Tuple of (most_played, least_played) library stats
|
||||||
|
"""
|
||||||
|
# First, get all libraries
|
||||||
|
libraries = await self.client.get_libraries()
|
||||||
|
library_map = {lib["Id"]: lib["Name"] for lib in libraries}
|
||||||
|
|
||||||
|
# Query playback stats aggregated by library
|
||||||
|
# Note: This assumes items have a ParentId that corresponds to library
|
||||||
|
# In practice, we may need to fetch item details to get the true library
|
||||||
|
query = """
|
||||||
|
SELECT
|
||||||
|
COUNT(*) as total_plays,
|
||||||
|
SUM(PlayDuration) as total_duration,
|
||||||
|
COUNT(DISTINCT UserId) as unique_users,
|
||||||
|
COUNT(DISTINCT ItemId) as unique_items
|
||||||
|
FROM PlaybackActivity
|
||||||
|
"""
|
||||||
|
|
||||||
|
# For now, create a single "All Libraries" stat
|
||||||
|
# In a real implementation, we'd need to query by ParentId or fetch item details
|
||||||
|
results = await self.client.query_playback_stats(query, replace_user_id=False)
|
||||||
|
|
||||||
|
if results:
|
||||||
|
row = results[0]
|
||||||
|
# Create stats for each library
|
||||||
|
stats = []
|
||||||
|
for lib_id, lib_name in library_map.items():
|
||||||
|
# For simplicity, we'll create placeholder stats
|
||||||
|
# A proper implementation would query per library
|
||||||
|
stats.append(
|
||||||
|
LibraryStats(
|
||||||
|
library_id=lib_id,
|
||||||
|
library_name=lib_name,
|
||||||
|
total_plays=int(row.get("total_plays", 0))
|
||||||
|
// len(library_map), # Divide equally as placeholder
|
||||||
|
total_duration_seconds=int(row.get("total_duration", 0))
|
||||||
|
// len(library_map),
|
||||||
|
unique_users=int(row.get("unique_users", 0)),
|
||||||
|
item_count=int(row.get("unique_items", 0))
|
||||||
|
// len(library_map),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
# Sort by total plays
|
||||||
|
stats_sorted = sorted(stats, key=lambda x: x.total_plays, reverse=True)
|
||||||
|
|
||||||
|
most_played = stats_sorted[:limit]
|
||||||
|
least_played = sorted(stats_sorted, key=lambda x: x.total_plays)[:limit]
|
||||||
|
|
||||||
|
return most_played, least_played
|
||||||
|
|
||||||
|
return [], []
|
||||||
0
src/jellycleanarr/ui/__init__.py
Normal file
0
src/jellycleanarr/ui/__init__.py
Normal file
32
src/jellycleanarr/ui/app.py
Normal file
32
src/jellycleanarr/ui/app.py
Normal file
@@ -0,0 +1,32 @@
|
|||||||
|
"""Main Textual application for Jellycleanarr."""
|
||||||
|
|
||||||
|
from pathlib import Path
|
||||||
|
from textual.app import App
|
||||||
|
from .screens.main_screen import MainScreen
|
||||||
|
from ..config import settings
|
||||||
|
from ..api.client import JellyfinClient
|
||||||
|
|
||||||
|
|
||||||
|
class JellycleanApp(App):
|
||||||
|
"""Jellycleanarr TUI Application."""
|
||||||
|
|
||||||
|
CSS_PATH = Path(__file__).parent / "styles" / "main.tcss"
|
||||||
|
TITLE = "Jellycleanarr - Jellyfin Statistics Viewer"
|
||||||
|
|
||||||
|
def __init__(self):
|
||||||
|
"""Initialize the app."""
|
||||||
|
super().__init__()
|
||||||
|
self.client = JellyfinClient(settings.jellyfin_address, settings.jellyfin_api_key)
|
||||||
|
|
||||||
|
async def on_mount(self) -> None:
|
||||||
|
"""Initialize app when mounted."""
|
||||||
|
try:
|
||||||
|
# Test connection
|
||||||
|
await self.client.test_connection()
|
||||||
|
self.push_screen(MainScreen(self.client))
|
||||||
|
except Exception as e:
|
||||||
|
self.exit(message=f"Failed to connect to Jellyfin: {e}")
|
||||||
|
|
||||||
|
async def on_unmount(self) -> None:
|
||||||
|
"""Cleanup when app closes."""
|
||||||
|
await self.client.close()
|
||||||
0
src/jellycleanarr/ui/screens/__init__.py
Normal file
0
src/jellycleanarr/ui/screens/__init__.py
Normal file
76
src/jellycleanarr/ui/screens/main_screen.py
Normal file
76
src/jellycleanarr/ui/screens/main_screen.py
Normal file
@@ -0,0 +1,76 @@
|
|||||||
|
"""Main screen with tabbed interface for displaying statistics."""
|
||||||
|
|
||||||
|
from textual.screen import Screen
|
||||||
|
from textual.widgets import Header, Footer, TabbedContent, TabPane
|
||||||
|
from ..widgets.user_stats import UserStatsWidget
|
||||||
|
from ..widgets.series_stats import SeriesStatsWidget
|
||||||
|
from ..widgets.library_stats import LibraryStatsWidget
|
||||||
|
from ...api.client import JellyfinClient
|
||||||
|
|
||||||
|
|
||||||
|
class MainScreen(Screen):
|
||||||
|
"""Main screen with tabbed statistics views."""
|
||||||
|
|
||||||
|
BINDINGS = [
|
||||||
|
("q", "quit", "Quit"),
|
||||||
|
("r", "refresh", "Refresh"),
|
||||||
|
("1", "switch_tab('users')", "Users"),
|
||||||
|
("2", "switch_tab('series')", "Series"),
|
||||||
|
("3", "switch_tab('libraries')", "Libraries"),
|
||||||
|
]
|
||||||
|
|
||||||
|
def __init__(self, client: JellyfinClient):
|
||||||
|
"""
|
||||||
|
Initialize main screen.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
client: Jellyfin API client
|
||||||
|
"""
|
||||||
|
super().__init__()
|
||||||
|
self.client = client
|
||||||
|
|
||||||
|
def compose(self):
|
||||||
|
"""Create the UI layout."""
|
||||||
|
yield Header()
|
||||||
|
|
||||||
|
with TabbedContent(initial="users"):
|
||||||
|
with TabPane("User Statistics", id="users"):
|
||||||
|
yield UserStatsWidget(self.client)
|
||||||
|
|
||||||
|
with TabPane("Series Statistics", id="series"):
|
||||||
|
yield SeriesStatsWidget(self.client)
|
||||||
|
|
||||||
|
with TabPane("Library Statistics", id="libraries"):
|
||||||
|
yield LibraryStatsWidget(self.client)
|
||||||
|
|
||||||
|
yield Footer()
|
||||||
|
|
||||||
|
async def action_refresh(self) -> None:
|
||||||
|
"""Refresh current tab data."""
|
||||||
|
# Get the active tab
|
||||||
|
tabs = self.query_one(TabbedContent)
|
||||||
|
active_pane = tabs.get_pane(tabs.active)
|
||||||
|
|
||||||
|
if active_pane:
|
||||||
|
# Find the stats widget in the active pane and refresh it
|
||||||
|
widget = active_pane.query_one(
|
||||||
|
"UserStatsWidget, SeriesStatsWidget, LibraryStatsWidget"
|
||||||
|
)
|
||||||
|
if hasattr(widget, "refresh_data"):
|
||||||
|
self.notify("Refreshing data...")
|
||||||
|
await widget.refresh_data()
|
||||||
|
self.notify("Data refreshed!", severity="information")
|
||||||
|
|
||||||
|
def action_switch_tab(self, tab_id: str) -> None:
|
||||||
|
"""
|
||||||
|
Switch to tab by ID.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
tab_id: ID of the tab to switch to (users, series, libraries)
|
||||||
|
"""
|
||||||
|
tabs = self.query_one(TabbedContent)
|
||||||
|
tabs.active = tab_id
|
||||||
|
|
||||||
|
def action_quit(self) -> None:
|
||||||
|
"""Quit the application."""
|
||||||
|
self.app.exit()
|
||||||
66
src/jellycleanarr/ui/styles/main.tcss
Normal file
66
src/jellycleanarr/ui/styles/main.tcss
Normal file
@@ -0,0 +1,66 @@
|
|||||||
|
/* Main application styling for Jellycleanarr TUI */
|
||||||
|
|
||||||
|
Screen {
|
||||||
|
background: $surface;
|
||||||
|
}
|
||||||
|
|
||||||
|
Header {
|
||||||
|
background: $primary;
|
||||||
|
color: $text;
|
||||||
|
text-style: bold;
|
||||||
|
}
|
||||||
|
|
||||||
|
Footer {
|
||||||
|
background: $primary-darken-2;
|
||||||
|
}
|
||||||
|
|
||||||
|
TabbedContent {
|
||||||
|
height: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
TabPane {
|
||||||
|
padding: 1 2;
|
||||||
|
}
|
||||||
|
|
||||||
|
StatsTable {
|
||||||
|
height: 100%;
|
||||||
|
border: solid $primary;
|
||||||
|
scrollbar-size: 1 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Widget containers need explicit height */
|
||||||
|
UserStatsWidget, SeriesStatsWidget, LibraryStatsWidget {
|
||||||
|
height: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
Horizontal {
|
||||||
|
height: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
Horizontal > StatsTable {
|
||||||
|
width: 1fr;
|
||||||
|
margin: 0 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* DataTable styling */
|
||||||
|
DataTable {
|
||||||
|
background: $surface;
|
||||||
|
color: $text;
|
||||||
|
}
|
||||||
|
|
||||||
|
DataTable > .datatable--header {
|
||||||
|
background: $primary;
|
||||||
|
color: $text;
|
||||||
|
text-style: bold;
|
||||||
|
}
|
||||||
|
|
||||||
|
DataTable > .datatable--cursor {
|
||||||
|
background: $accent;
|
||||||
|
color: $text;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Color scheme (using Textual defaults with custom primary) */
|
||||||
|
$primary: #0078d4;
|
||||||
|
$accent: #005a9e;
|
||||||
|
$surface: #1e1e1e;
|
||||||
|
$text: #ffffff;
|
||||||
0
src/jellycleanarr/ui/widgets/__init__.py
Normal file
0
src/jellycleanarr/ui/widgets/__init__.py
Normal file
103
src/jellycleanarr/ui/widgets/library_stats.py
Normal file
103
src/jellycleanarr/ui/widgets/library_stats.py
Normal file
@@ -0,0 +1,103 @@
|
|||||||
|
"""Library statistics widget displaying library playback data."""
|
||||||
|
|
||||||
|
from textual.widget import Widget
|
||||||
|
from textual.containers import Horizontal
|
||||||
|
from .stats_table import StatsTable
|
||||||
|
from ...api.client import JellyfinClient
|
||||||
|
from ...services.stats_service import StatsService
|
||||||
|
from ...utils.formatters import format_duration, format_number
|
||||||
|
|
||||||
|
|
||||||
|
class LibraryStatsWidget(Widget):
|
||||||
|
"""Widget displaying library playback statistics."""
|
||||||
|
|
||||||
|
def __init__(self, client: JellyfinClient, **kwargs):
|
||||||
|
"""
|
||||||
|
Initialize library stats widget.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
client: Jellyfin API client
|
||||||
|
**kwargs: Additional arguments passed to Widget
|
||||||
|
"""
|
||||||
|
super().__init__(**kwargs)
|
||||||
|
self.client = client
|
||||||
|
self.stats_service = StatsService(client)
|
||||||
|
|
||||||
|
def compose(self):
|
||||||
|
"""Create layout with two tables side by side."""
|
||||||
|
with Horizontal():
|
||||||
|
# Most played libraries table
|
||||||
|
yield StatsTable(
|
||||||
|
"Most Active Libraries",
|
||||||
|
columns=[
|
||||||
|
("Library", 25),
|
||||||
|
("Plays", 12),
|
||||||
|
("Duration", 15),
|
||||||
|
("Users", 10),
|
||||||
|
("Items", 10),
|
||||||
|
],
|
||||||
|
id="most-active-libraries",
|
||||||
|
)
|
||||||
|
|
||||||
|
# Least played libraries table
|
||||||
|
yield StatsTable(
|
||||||
|
"Least Active Libraries",
|
||||||
|
columns=[
|
||||||
|
("Library", 25),
|
||||||
|
("Plays", 12),
|
||||||
|
("Duration", 15),
|
||||||
|
("Users", 10),
|
||||||
|
("Items", 10),
|
||||||
|
],
|
||||||
|
id="least-active-libraries",
|
||||||
|
)
|
||||||
|
|
||||||
|
def on_mount(self) -> None:
|
||||||
|
"""Load data when widget is mounted."""
|
||||||
|
self.log("LibraryStatsWidget mounted, scheduling refresh_data...")
|
||||||
|
self.call_after_refresh(self.refresh_data)
|
||||||
|
self.log("LibraryStatsWidget refresh_data scheduled")
|
||||||
|
|
||||||
|
async def refresh_data(self) -> None:
|
||||||
|
"""Fetch and display library statistics."""
|
||||||
|
try:
|
||||||
|
self.log("Fetching library stats from API...")
|
||||||
|
most_played, least_played = await self.stats_service.get_library_stats()
|
||||||
|
self.log(f"Got {len(most_played)} most active, {len(least_played)} least active libraries")
|
||||||
|
|
||||||
|
# Update most played table
|
||||||
|
most_table = self.query_one("#most-active-libraries", StatsTable)
|
||||||
|
most_rows = [
|
||||||
|
[
|
||||||
|
stat.library_name,
|
||||||
|
format_number(stat.total_plays),
|
||||||
|
format_duration(stat.total_duration_seconds),
|
||||||
|
format_number(stat.unique_users),
|
||||||
|
format_number(stat.item_count),
|
||||||
|
]
|
||||||
|
for stat in most_played
|
||||||
|
]
|
||||||
|
most_table.update_data(most_rows)
|
||||||
|
|
||||||
|
# Update least played table
|
||||||
|
least_table = self.query_one("#least-active-libraries", StatsTable)
|
||||||
|
least_rows = [
|
||||||
|
[
|
||||||
|
stat.library_name,
|
||||||
|
format_number(stat.total_plays),
|
||||||
|
format_duration(stat.total_duration_seconds),
|
||||||
|
format_number(stat.unique_users),
|
||||||
|
format_number(stat.item_count),
|
||||||
|
]
|
||||||
|
for stat in least_played
|
||||||
|
]
|
||||||
|
least_table.update_data(least_rows)
|
||||||
|
|
||||||
|
self.notify("Library stats loaded successfully", severity="information")
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
# Log error and show notification
|
||||||
|
self.log(f"ERROR in refresh_data: {e}")
|
||||||
|
import traceback
|
||||||
|
self.log(traceback.format_exc())
|
||||||
|
self.notify(f"Failed to load library stats: {e}", severity="error")
|
||||||
99
src/jellycleanarr/ui/widgets/series_stats.py
Normal file
99
src/jellycleanarr/ui/widgets/series_stats.py
Normal file
@@ -0,0 +1,99 @@
|
|||||||
|
"""Series statistics widget displaying series/movie playback data."""
|
||||||
|
|
||||||
|
from textual.widget import Widget
|
||||||
|
from textual.containers import Horizontal
|
||||||
|
from .stats_table import StatsTable
|
||||||
|
from ...api.client import JellyfinClient
|
||||||
|
from ...services.stats_service import StatsService
|
||||||
|
from ...utils.formatters import format_duration, format_number
|
||||||
|
|
||||||
|
|
||||||
|
class SeriesStatsWidget(Widget):
|
||||||
|
"""Widget displaying series playback statistics."""
|
||||||
|
|
||||||
|
def __init__(self, client: JellyfinClient, **kwargs):
|
||||||
|
"""
|
||||||
|
Initialize series stats widget.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
client: Jellyfin API client
|
||||||
|
**kwargs: Additional arguments passed to Widget
|
||||||
|
"""
|
||||||
|
super().__init__(**kwargs)
|
||||||
|
self.client = client
|
||||||
|
self.stats_service = StatsService(client)
|
||||||
|
|
||||||
|
def compose(self):
|
||||||
|
"""Create layout with two tables side by side."""
|
||||||
|
with Horizontal():
|
||||||
|
# Most played series table
|
||||||
|
yield StatsTable(
|
||||||
|
"Most Watched Series",
|
||||||
|
columns=[
|
||||||
|
("Title", 30),
|
||||||
|
("Plays", 12),
|
||||||
|
("Duration", 15),
|
||||||
|
("Users", 10),
|
||||||
|
],
|
||||||
|
id="most-watched-series",
|
||||||
|
)
|
||||||
|
|
||||||
|
# Least played series table
|
||||||
|
yield StatsTable(
|
||||||
|
"Least Watched Series",
|
||||||
|
columns=[
|
||||||
|
("Title", 30),
|
||||||
|
("Plays", 12),
|
||||||
|
("Duration", 15),
|
||||||
|
("Users", 10),
|
||||||
|
],
|
||||||
|
id="least-watched-series",
|
||||||
|
)
|
||||||
|
|
||||||
|
def on_mount(self) -> None:
|
||||||
|
"""Load data when widget is mounted."""
|
||||||
|
self.log("SeriesStatsWidget mounted, scheduling refresh_data...")
|
||||||
|
self.call_after_refresh(self.refresh_data)
|
||||||
|
self.log("SeriesStatsWidget refresh_data scheduled")
|
||||||
|
|
||||||
|
async def refresh_data(self) -> None:
|
||||||
|
"""Fetch and display series statistics."""
|
||||||
|
try:
|
||||||
|
self.log("Fetching series stats from API...")
|
||||||
|
most_played, least_played = await self.stats_service.get_series_stats()
|
||||||
|
self.log(f"Got {len(most_played)} most watched, {len(least_played)} least watched series")
|
||||||
|
|
||||||
|
# Update most played table
|
||||||
|
most_table = self.query_one("#most-watched-series", StatsTable)
|
||||||
|
most_rows = [
|
||||||
|
[
|
||||||
|
stat.series_name,
|
||||||
|
format_number(stat.total_plays),
|
||||||
|
format_duration(stat.total_duration_seconds),
|
||||||
|
format_number(stat.unique_users),
|
||||||
|
]
|
||||||
|
for stat in most_played
|
||||||
|
]
|
||||||
|
most_table.update_data(most_rows)
|
||||||
|
|
||||||
|
# Update least played table
|
||||||
|
least_table = self.query_one("#least-watched-series", StatsTable)
|
||||||
|
least_rows = [
|
||||||
|
[
|
||||||
|
stat.series_name,
|
||||||
|
format_number(stat.total_plays),
|
||||||
|
format_duration(stat.total_duration_seconds),
|
||||||
|
format_number(stat.unique_users),
|
||||||
|
]
|
||||||
|
for stat in least_played
|
||||||
|
]
|
||||||
|
least_table.update_data(least_rows)
|
||||||
|
|
||||||
|
self.notify("Series stats loaded successfully", severity="information")
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
# Log error and show notification
|
||||||
|
self.log(f"ERROR in refresh_data: {e}")
|
||||||
|
import traceback
|
||||||
|
self.log(traceback.format_exc())
|
||||||
|
self.notify(f"Failed to load series stats: {e}", severity="error")
|
||||||
45
src/jellycleanarr/ui/widgets/stats_table.py
Normal file
45
src/jellycleanarr/ui/widgets/stats_table.py
Normal file
@@ -0,0 +1,45 @@
|
|||||||
|
"""Reusable table widget for displaying statistics."""
|
||||||
|
|
||||||
|
from typing import List, Tuple, Any
|
||||||
|
from textual.widgets import DataTable
|
||||||
|
|
||||||
|
|
||||||
|
class StatsTable(DataTable):
|
||||||
|
"""Enhanced DataTable for statistics display."""
|
||||||
|
|
||||||
|
def __init__(self, title: str, columns: List[Tuple[str, int]], **kwargs):
|
||||||
|
"""
|
||||||
|
Initialize stats table.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
title: Table title displayed in border
|
||||||
|
columns: List of (column_name, width) tuples
|
||||||
|
**kwargs: Additional arguments passed to DataTable
|
||||||
|
"""
|
||||||
|
super().__init__(**kwargs)
|
||||||
|
self.border_title = title
|
||||||
|
self.cursor_type = "row"
|
||||||
|
self.zebra_stripes = True
|
||||||
|
self._columns_config = columns
|
||||||
|
self._columns_added = False
|
||||||
|
|
||||||
|
def on_mount(self) -> None:
|
||||||
|
"""Add columns when widget is mounted."""
|
||||||
|
if not self._columns_added:
|
||||||
|
for col_name, col_width in self._columns_config:
|
||||||
|
self.add_column(col_name, width=col_width)
|
||||||
|
self._columns_added = True
|
||||||
|
self.log(f"Added {len(self._columns_config)} columns to {self.border_title}")
|
||||||
|
|
||||||
|
def update_data(self, rows: List[List[Any]]) -> None:
|
||||||
|
"""
|
||||||
|
Update table with new data.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
rows: List of rows, where each row is a list of cell values
|
||||||
|
"""
|
||||||
|
self.log(f"Updating {self.border_title} with {len(rows)} rows")
|
||||||
|
self.clear()
|
||||||
|
for row in rows:
|
||||||
|
self.add_row(*[str(cell) for cell in row])
|
||||||
|
self.log(f"Update complete for {self.border_title}, now has {self.row_count} rows")
|
||||||
110
src/jellycleanarr/ui/widgets/user_stats.py
Normal file
110
src/jellycleanarr/ui/widgets/user_stats.py
Normal file
@@ -0,0 +1,110 @@
|
|||||||
|
"""User statistics widget displaying user playback data."""
|
||||||
|
|
||||||
|
from textual.widget import Widget
|
||||||
|
from textual.containers import Horizontal
|
||||||
|
from .stats_table import StatsTable
|
||||||
|
from ...api.client import JellyfinClient
|
||||||
|
from ...services.stats_service import StatsService
|
||||||
|
from ...utils.formatters import format_duration, format_number
|
||||||
|
|
||||||
|
|
||||||
|
class UserStatsWidget(Widget):
|
||||||
|
"""Widget displaying user playback statistics."""
|
||||||
|
|
||||||
|
def __init__(self, client: JellyfinClient, **kwargs):
|
||||||
|
"""
|
||||||
|
Initialize user stats widget.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
client: Jellyfin API client
|
||||||
|
**kwargs: Additional arguments passed to Widget
|
||||||
|
"""
|
||||||
|
super().__init__(**kwargs)
|
||||||
|
self.client = client
|
||||||
|
self.stats_service = StatsService(client)
|
||||||
|
|
||||||
|
def compose(self):
|
||||||
|
"""Create layout with two tables side by side."""
|
||||||
|
with Horizontal():
|
||||||
|
# Most played users table
|
||||||
|
yield StatsTable(
|
||||||
|
"Most Active Users",
|
||||||
|
columns=[
|
||||||
|
("User", 20),
|
||||||
|
("Plays", 12),
|
||||||
|
("Duration", 15),
|
||||||
|
("Unique Items", 13),
|
||||||
|
],
|
||||||
|
id="most-active-users",
|
||||||
|
)
|
||||||
|
|
||||||
|
# Least played users table
|
||||||
|
yield StatsTable(
|
||||||
|
"Least Active Users",
|
||||||
|
columns=[
|
||||||
|
("User", 20),
|
||||||
|
("Plays", 12),
|
||||||
|
("Duration", 15),
|
||||||
|
("Unique Items", 13),
|
||||||
|
],
|
||||||
|
id="least-active-users",
|
||||||
|
)
|
||||||
|
|
||||||
|
def on_mount(self) -> None:
|
||||||
|
"""Load data when widget is mounted."""
|
||||||
|
self.log("UserStatsWidget mounted, scheduling refresh_data...")
|
||||||
|
# Use call_after_refresh to ensure tables are fully mounted before updating
|
||||||
|
self.call_after_refresh(self.refresh_data)
|
||||||
|
self.log("UserStatsWidget refresh_data scheduled")
|
||||||
|
|
||||||
|
async def refresh_data(self) -> None:
|
||||||
|
"""Fetch and display user statistics."""
|
||||||
|
try:
|
||||||
|
self.log("Fetching user stats from API...")
|
||||||
|
most_played, least_played = await self.stats_service.get_user_stats()
|
||||||
|
self.log(f"Got {len(most_played)} most active, {len(least_played)} least active users")
|
||||||
|
|
||||||
|
# Update most played table
|
||||||
|
self.log("Querying for most-active-users table...")
|
||||||
|
most_table = self.query_one("#most-active-users", StatsTable)
|
||||||
|
self.log(f"Found most_table: {most_table}, columns_added: {most_table._columns_added}")
|
||||||
|
|
||||||
|
most_rows = [
|
||||||
|
[
|
||||||
|
stat.username,
|
||||||
|
format_number(stat.total_plays),
|
||||||
|
format_duration(stat.total_duration_seconds),
|
||||||
|
format_number(stat.unique_items_played),
|
||||||
|
]
|
||||||
|
for stat in most_played
|
||||||
|
]
|
||||||
|
self.log(f"Created {len(most_rows)} rows for most active users")
|
||||||
|
most_table.update_data(most_rows)
|
||||||
|
self.log(f"Updated most_table, now has {most_table.row_count} rows")
|
||||||
|
|
||||||
|
# Update least played table
|
||||||
|
self.log("Querying for least-active-users table...")
|
||||||
|
least_table = self.query_one("#least-active-users", StatsTable)
|
||||||
|
self.log(f"Found least_table: {least_table}, columns_added: {least_table._columns_added}")
|
||||||
|
|
||||||
|
least_rows = [
|
||||||
|
[
|
||||||
|
stat.username,
|
||||||
|
format_number(stat.total_plays),
|
||||||
|
format_duration(stat.total_duration_seconds),
|
||||||
|
format_number(stat.unique_items_played),
|
||||||
|
]
|
||||||
|
for stat in least_played
|
||||||
|
]
|
||||||
|
self.log(f"Created {len(least_rows)} rows for least active users")
|
||||||
|
least_table.update_data(least_rows)
|
||||||
|
self.log(f"Updated least_table, now has {least_table.row_count} rows")
|
||||||
|
|
||||||
|
self.notify("User stats loaded successfully", severity="information")
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
# Log error and show notification
|
||||||
|
self.log(f"ERROR in refresh_data: {e}")
|
||||||
|
import traceback
|
||||||
|
self.log(traceback.format_exc())
|
||||||
|
self.notify(f"Failed to load user stats: {e}", severity="error")
|
||||||
0
src/jellycleanarr/utils/__init__.py
Normal file
0
src/jellycleanarr/utils/__init__.py
Normal file
25
src/jellycleanarr/utils/exceptions.py
Normal file
25
src/jellycleanarr/utils/exceptions.py
Normal file
@@ -0,0 +1,25 @@
|
|||||||
|
"""Custom exception classes for Jellycleanarr."""
|
||||||
|
|
||||||
|
|
||||||
|
class JellyfinError(Exception):
|
||||||
|
"""Base exception for Jellyfin-related errors."""
|
||||||
|
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
class JellyfinConnectionError(JellyfinError):
|
||||||
|
"""Raised when cannot connect to Jellyfin server."""
|
||||||
|
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
class JellyfinAuthError(JellyfinError):
|
||||||
|
"""Raised when authentication fails."""
|
||||||
|
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
class JellyfinAPIError(JellyfinError):
|
||||||
|
"""Raised when API returns an error."""
|
||||||
|
|
||||||
|
pass
|
||||||
39
src/jellycleanarr/utils/formatters.py
Normal file
39
src/jellycleanarr/utils/formatters.py
Normal file
@@ -0,0 +1,39 @@
|
|||||||
|
"""Formatting utilities for display."""
|
||||||
|
|
||||||
|
from datetime import timedelta
|
||||||
|
|
||||||
|
|
||||||
|
def format_duration(seconds: int) -> str:
|
||||||
|
"""
|
||||||
|
Format duration in seconds to human-readable string.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
seconds: Duration in seconds
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Formatted string like "5d 12h 30m" or "2h 15m" or "45m"
|
||||||
|
"""
|
||||||
|
td = timedelta(seconds=seconds)
|
||||||
|
days = td.days
|
||||||
|
hours = td.seconds // 3600
|
||||||
|
minutes = (td.seconds % 3600) // 60
|
||||||
|
|
||||||
|
if days > 0:
|
||||||
|
return f"{days}d {hours}h {minutes}m"
|
||||||
|
elif hours > 0:
|
||||||
|
return f"{hours}h {minutes}m"
|
||||||
|
else:
|
||||||
|
return f"{minutes}m"
|
||||||
|
|
||||||
|
|
||||||
|
def format_number(num: int) -> str:
|
||||||
|
"""
|
||||||
|
Format large numbers with thousand separators.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
num: Number to format
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Formatted string with commas (e.g., "1,234,567")
|
||||||
|
"""
|
||||||
|
return f"{num:,}"
|
||||||
263
styles.css
Normal file
263
styles.css
Normal file
@@ -0,0 +1,263 @@
|
|||||||
|
:root {
|
||||||
|
--bg: #f4efe6;
|
||||||
|
--panel: #fffaf1;
|
||||||
|
--panel-2: #f8f0e2;
|
||||||
|
--line: #d8ccb8;
|
||||||
|
--text: #2f2418;
|
||||||
|
--muted: #7d6852;
|
||||||
|
--accent: #9c3f1f;
|
||||||
|
--accent-2: #4f6f52;
|
||||||
|
--focus: #c77800;
|
||||||
|
}
|
||||||
|
|
||||||
|
* {
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
|
||||||
|
body {
|
||||||
|
margin: 0;
|
||||||
|
font-family: "Trebuchet MS", "Segoe UI", sans-serif;
|
||||||
|
color: var(--text);
|
||||||
|
background:
|
||||||
|
radial-gradient(1200px 700px at 95% 0%, #e2c8a0 0%, transparent 60%),
|
||||||
|
linear-gradient(145deg, #f8f2e7 0%, var(--bg) 100%);
|
||||||
|
min-height: 100vh;
|
||||||
|
}
|
||||||
|
|
||||||
|
.topbar {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: flex-end;
|
||||||
|
gap: 1rem;
|
||||||
|
padding: 1.1rem 1.3rem 1rem;
|
||||||
|
border-bottom: 1px solid var(--line);
|
||||||
|
background: rgba(255, 250, 241, 0.8);
|
||||||
|
backdrop-filter: blur(2px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.topbar h1 {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 1.3rem;
|
||||||
|
letter-spacing: 0.04em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.topbar p {
|
||||||
|
margin: 0.25rem 0 0;
|
||||||
|
color: var(--muted);
|
||||||
|
font-size: 0.9rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.actions {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.55rem;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
justify-content: flex-end;
|
||||||
|
}
|
||||||
|
|
||||||
|
.file-button,
|
||||||
|
button {
|
||||||
|
border: 1px solid var(--accent);
|
||||||
|
color: #fff;
|
||||||
|
background: var(--accent);
|
||||||
|
padding: 0.48rem 0.75rem;
|
||||||
|
border-radius: 0.5rem;
|
||||||
|
cursor: pointer;
|
||||||
|
font-size: 0.85rem;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.file-button.secondary {
|
||||||
|
border-color: var(--accent-2);
|
||||||
|
background: var(--accent-2);
|
||||||
|
}
|
||||||
|
|
||||||
|
.file-button input {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
button:hover,
|
||||||
|
.file-button:hover {
|
||||||
|
filter: brightness(1.05);
|
||||||
|
}
|
||||||
|
|
||||||
|
button:focus-visible,
|
||||||
|
.file-button:focus-within {
|
||||||
|
outline: 2px solid var(--focus);
|
||||||
|
outline-offset: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.status {
|
||||||
|
padding: 0.5rem 1.3rem;
|
||||||
|
font-size: 0.88rem;
|
||||||
|
color: var(--muted);
|
||||||
|
border-bottom: 1px solid var(--line);
|
||||||
|
}
|
||||||
|
|
||||||
|
.layout {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: minmax(320px, 1.1fr) minmax(320px, 0.9fr);
|
||||||
|
gap: 1rem;
|
||||||
|
padding: 1rem 1.3rem 1.3rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.panel {
|
||||||
|
background: var(--panel);
|
||||||
|
border: 1px solid var(--line);
|
||||||
|
border-radius: 0.8rem;
|
||||||
|
min-height: 66vh;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
|
||||||
|
.panel-head {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.75rem;
|
||||||
|
padding: 0.75rem 0.8rem;
|
||||||
|
border-bottom: 1px solid var(--line);
|
||||||
|
background: var(--panel-2);
|
||||||
|
}
|
||||||
|
|
||||||
|
.panel-head h2 {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.filters {
|
||||||
|
display: flex;
|
||||||
|
gap: 0.45rem;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
input[type="search"],
|
||||||
|
select,
|
||||||
|
input[type="number"],
|
||||||
|
input[type="text"] {
|
||||||
|
border: 1px solid var(--line);
|
||||||
|
background: #fff;
|
||||||
|
color: var(--text);
|
||||||
|
border-radius: 0.45rem;
|
||||||
|
padding: 0.35rem 0.45rem;
|
||||||
|
font-size: 0.85rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
input:focus-visible,
|
||||||
|
select:focus-visible {
|
||||||
|
outline: 2px solid var(--focus);
|
||||||
|
outline-offset: 1px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.table-wrap {
|
||||||
|
overflow: auto;
|
||||||
|
flex: 1 1 auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
table {
|
||||||
|
width: 100%;
|
||||||
|
border-collapse: collapse;
|
||||||
|
}
|
||||||
|
|
||||||
|
th,
|
||||||
|
td {
|
||||||
|
text-align: left;
|
||||||
|
padding: 0.45rem 0.55rem;
|
||||||
|
border-bottom: 1px solid var(--line);
|
||||||
|
font-size: 0.83rem;
|
||||||
|
vertical-align: middle;
|
||||||
|
}
|
||||||
|
|
||||||
|
tbody tr {
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
tbody tr:hover {
|
||||||
|
background: #fff2de;
|
||||||
|
}
|
||||||
|
|
||||||
|
tbody tr.selected {
|
||||||
|
background: #f9dfbc;
|
||||||
|
}
|
||||||
|
|
||||||
|
.details-panel {
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.details-body {
|
||||||
|
padding: 0.8rem;
|
||||||
|
overflow: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.details-empty {
|
||||||
|
padding: 1.1rem;
|
||||||
|
color: var(--muted);
|
||||||
|
font-size: 0.9rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.item-header {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.6rem;
|
||||||
|
margin-bottom: 0.7rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.item-title {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.badge {
|
||||||
|
border: 1px solid var(--line);
|
||||||
|
border-radius: 999px;
|
||||||
|
padding: 0.15rem 0.5rem;
|
||||||
|
font-size: 0.75rem;
|
||||||
|
color: var(--muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.inline-row {
|
||||||
|
display: flex;
|
||||||
|
gap: 0.5rem;
|
||||||
|
align-items: center;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
margin-bottom: 0.65rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.placements-table th,
|
||||||
|
.placements-table td {
|
||||||
|
font-size: 0.8rem;
|
||||||
|
padding: 0.34rem 0.4rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.small-btn {
|
||||||
|
border: 1px solid var(--line);
|
||||||
|
background: #fff;
|
||||||
|
color: var(--text);
|
||||||
|
border-radius: 0.35rem;
|
||||||
|
padding: 0.2rem 0.45rem;
|
||||||
|
font-size: 0.75rem;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.small-btn.remove {
|
||||||
|
border-color: #b74a38;
|
||||||
|
color: #8f2617;
|
||||||
|
}
|
||||||
|
|
||||||
|
.meta {
|
||||||
|
margin: 0.7rem 0 0;
|
||||||
|
color: var(--muted);
|
||||||
|
font-size: 0.78rem;
|
||||||
|
line-height: 1.4;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 980px) {
|
||||||
|
.layout {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
|
|
||||||
|
.panel {
|
||||||
|
min-height: 45vh;
|
||||||
|
}
|
||||||
|
}
|
||||||
96
test_data_flow.py
Normal file
96
test_data_flow.py
Normal file
@@ -0,0 +1,96 @@
|
|||||||
|
"""Test script to verify data flow and formatting."""
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
import sys
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
# Add src to path
|
||||||
|
sys.path.insert(0, str(Path(__file__).parent / "src"))
|
||||||
|
|
||||||
|
from jellycleanarr.config import settings
|
||||||
|
from jellycleanarr.api.client import JellyfinClient
|
||||||
|
from jellycleanarr.services.stats_service import StatsService
|
||||||
|
from jellycleanarr.utils.formatters import format_duration, format_number
|
||||||
|
|
||||||
|
|
||||||
|
async def test_data_flow():
|
||||||
|
"""Test the complete data flow from API to formatted output."""
|
||||||
|
print("=" * 80)
|
||||||
|
print("Testing Jellycleanarr Data Flow")
|
||||||
|
print("=" * 80)
|
||||||
|
|
||||||
|
# Initialize client
|
||||||
|
print(f"\n1. Connecting to: {settings.jellyfin_address}")
|
||||||
|
client = JellyfinClient(settings.jellyfin_address, settings.jellyfin_api_key)
|
||||||
|
|
||||||
|
try:
|
||||||
|
# Test connection
|
||||||
|
await client.test_connection()
|
||||||
|
print("[OK] Connection successful")
|
||||||
|
|
||||||
|
# Initialize stats service
|
||||||
|
stats_service = StatsService(client)
|
||||||
|
|
||||||
|
# Get user stats
|
||||||
|
print("\n2. Fetching user stats...")
|
||||||
|
most_played, least_played = await stats_service.get_user_stats()
|
||||||
|
print(f"[OK] Got {len(most_played)} most active users")
|
||||||
|
print(f"[OK] Got {len(least_played)} least active users")
|
||||||
|
|
||||||
|
# Display raw data for first user
|
||||||
|
if most_played:
|
||||||
|
print("\n3. Raw data for most active user:")
|
||||||
|
user = most_played[0]
|
||||||
|
print(f" Username: {user.username}")
|
||||||
|
print(f" Total plays: {user.total_plays}")
|
||||||
|
print(f" Total duration (raw): {user.total_duration_seconds}")
|
||||||
|
print(f" Unique items: {user.unique_items_played}")
|
||||||
|
|
||||||
|
# Test formatting
|
||||||
|
print("\n4. Testing formatters:")
|
||||||
|
try:
|
||||||
|
formatted_duration = format_duration(user.total_duration_seconds)
|
||||||
|
print(f" Formatted duration: {formatted_duration}")
|
||||||
|
except Exception as e:
|
||||||
|
print(f" ERROR formatting duration: {e}")
|
||||||
|
|
||||||
|
formatted_plays = format_number(user.total_plays)
|
||||||
|
print(f" Formatted plays: {formatted_plays}")
|
||||||
|
formatted_items = format_number(user.unique_items_played)
|
||||||
|
print(f" Formatted items: {formatted_items}")
|
||||||
|
|
||||||
|
# Create formatted row (as TUI would)
|
||||||
|
print("\n5. Creating table row (as TUI would):")
|
||||||
|
row = [
|
||||||
|
user.username,
|
||||||
|
format_number(user.total_plays),
|
||||||
|
format_duration(user.total_duration_seconds),
|
||||||
|
format_number(user.unique_items_played),
|
||||||
|
]
|
||||||
|
print(f" Row: {row}")
|
||||||
|
|
||||||
|
# Convert to strings (as StatsTable does)
|
||||||
|
string_row = [str(cell) for cell in row]
|
||||||
|
print(f" String row: {string_row}")
|
||||||
|
|
||||||
|
# Display all users
|
||||||
|
print("\n6. All formatted users:")
|
||||||
|
print("-" * 80)
|
||||||
|
print(f"{'User':<20} {'Plays':>12} {'Duration':>15} {'Items':>13}")
|
||||||
|
print("-" * 80)
|
||||||
|
for user in most_played:
|
||||||
|
print(f"{user.username:<20} {format_number(user.total_plays):>12} "
|
||||||
|
f"{format_duration(user.total_duration_seconds):>15} "
|
||||||
|
f"{format_number(user.unique_items_played):>13}")
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
print(f"\nERROR: {e}")
|
||||||
|
import traceback
|
||||||
|
traceback.print_exc()
|
||||||
|
finally:
|
||||||
|
await client.close()
|
||||||
|
print("\n[OK] Connection closed")
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
asyncio.run(test_data_flow())
|
||||||
42
test_tui_minimal.py
Normal file
42
test_tui_minimal.py
Normal file
@@ -0,0 +1,42 @@
|
|||||||
|
"""Minimal TUI test to verify DataTable rendering."""
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
import sys
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
# Add src to path
|
||||||
|
sys.path.insert(0, str(Path(__file__).parent / "src"))
|
||||||
|
|
||||||
|
from textual.app import App
|
||||||
|
from textual.widgets import Header, Footer, DataTable
|
||||||
|
from textual.containers import Container
|
||||||
|
|
||||||
|
|
||||||
|
class TestApp(App):
|
||||||
|
"""Minimal test app to verify DataTable works."""
|
||||||
|
|
||||||
|
def compose(self):
|
||||||
|
"""Create layout."""
|
||||||
|
yield Header()
|
||||||
|
yield DataTable()
|
||||||
|
yield Footer()
|
||||||
|
|
||||||
|
def on_mount(self) -> None:
|
||||||
|
"""Add test data when mounted."""
|
||||||
|
table = self.query_one(DataTable)
|
||||||
|
table.add_column("User", width=20)
|
||||||
|
table.add_column("Plays", width=12)
|
||||||
|
table.add_column("Duration", width=15)
|
||||||
|
table.add_column("Items", width=13)
|
||||||
|
|
||||||
|
# Add test rows (same data from our test)
|
||||||
|
table.add_row("HRiggs", "346", "3h 9m", "153")
|
||||||
|
table.add_row("TV", "204", "5d 1h 16m", "115")
|
||||||
|
table.add_row("Katenoel", "111", "3d 11h 13m", "69")
|
||||||
|
|
||||||
|
self.notify(f"Added {table.row_count} rows to table")
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
app = TestApp()
|
||||||
|
app.run()
|
||||||
82
test_widget_direct.py
Normal file
82
test_widget_direct.py
Normal file
@@ -0,0 +1,82 @@
|
|||||||
|
"""Direct test of widget mounting and data loading."""
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
import sys
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
# Add src to path
|
||||||
|
sys.path.insert(0, str(Path(__file__).parent / "src"))
|
||||||
|
|
||||||
|
from jellycleanarr.config import settings
|
||||||
|
from jellycleanarr.api.client import JellyfinClient
|
||||||
|
from jellycleanarr.ui.widgets.user_stats import UserStatsWidget
|
||||||
|
from jellycleanarr.ui.widgets.stats_table import StatsTable
|
||||||
|
|
||||||
|
|
||||||
|
async def test_widget():
|
||||||
|
"""Test widget creation and data loading."""
|
||||||
|
print("Creating Jellyfin client...")
|
||||||
|
client = JellyfinClient(settings.jellyfin_address, settings.jellyfin_api_key)
|
||||||
|
|
||||||
|
try:
|
||||||
|
await client.test_connection()
|
||||||
|
print("[OK] Connected to Jellyfin")
|
||||||
|
|
||||||
|
# Create the widget
|
||||||
|
print("\nCreating UserStatsWidget...")
|
||||||
|
widget = UserStatsWidget(client)
|
||||||
|
|
||||||
|
# Manually call compose to create child widgets
|
||||||
|
print("Calling compose()...")
|
||||||
|
widgets = list(widget.compose())
|
||||||
|
print(f"[OK] Compose created {len(widgets)} widgets")
|
||||||
|
|
||||||
|
for i, w in enumerate(widgets):
|
||||||
|
print(f" Widget {i}: {type(w).__name__}")
|
||||||
|
if hasattr(w, 'compose'):
|
||||||
|
children = list(w.compose())
|
||||||
|
for j, child in enumerate(children):
|
||||||
|
print(f" Child {j}: {type(child).__name__} - id={getattr(child, 'id', 'no-id')}")
|
||||||
|
|
||||||
|
# Try to manually create and populate a table
|
||||||
|
print("\nManually creating StatsTable...")
|
||||||
|
table = StatsTable(
|
||||||
|
"Test Table",
|
||||||
|
columns=[
|
||||||
|
("User", 20),
|
||||||
|
("Plays", 12),
|
||||||
|
("Duration", 15),
|
||||||
|
("Items", 13),
|
||||||
|
],
|
||||||
|
id="test-table",
|
||||||
|
)
|
||||||
|
|
||||||
|
# Simulate on_mount
|
||||||
|
print("Simulating table on_mount (adding columns)...")
|
||||||
|
for col_name, col_width in table._columns_config:
|
||||||
|
table.add_column(col_name, width=col_width)
|
||||||
|
table._columns_added = True
|
||||||
|
print(f"[OK] Table has {len(table.columns)} columns")
|
||||||
|
|
||||||
|
# Add test data
|
||||||
|
print("Adding test rows...")
|
||||||
|
test_rows = [
|
||||||
|
["HRiggs", "346", "3h 9m", "153"],
|
||||||
|
["TV", "204", "5d 1h 16m", "115"],
|
||||||
|
]
|
||||||
|
table.update_data(test_rows)
|
||||||
|
print(f"[OK] Table has {table.row_count} rows")
|
||||||
|
|
||||||
|
print("\n=== Widget structure looks OK ===")
|
||||||
|
print("The issue might be in the async mounting order or Textual rendering.")
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
print(f"\nERROR: {e}")
|
||||||
|
import traceback
|
||||||
|
traceback.print_exc()
|
||||||
|
finally:
|
||||||
|
await client.close()
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
asyncio.run(test_widget())
|
||||||
0
tests/__init__.py
Normal file
0
tests/__init__.py
Normal file
0
tests/test_api/__init__.py
Normal file
0
tests/test_api/__init__.py
Normal file
0
tests/test_models/__init__.py
Normal file
0
tests/test_models/__init__.py
Normal file
0
tests/test_services/__init__.py
Normal file
0
tests/test_services/__init__.py
Normal file
Reference in New Issue
Block a user