From 49edd5ba84393760e9f2381f7b5237fcf143f822 Mon Sep 17 00:00:00 2001 From: HRiggs Date: Thu, 12 Feb 2026 00:05:42 -0500 Subject: [PATCH] updates --- .claude/settings.local.json | 10 + .domains | 1 + .env.example | 13 + .gitignore | 140 ++ DEBUGGING_NOTES.md | 57 + FIXES.md | 64 + README.md | 192 +- TROUBLESHOOTING.md | 111 ++ app.js | 557 ++++++ index.html | 77 + jellycleanarr_data_dump.txt | 1591 +++++++++++++++++ pyproject.toml | 49 + run_with_logging.py | 14 + src/jellycleanarr/__init__.py | 0 src/jellycleanarr/__main__.py | 7 + src/jellycleanarr/api/__init__.py | 0 src/jellycleanarr/api/client.py | 171 ++ src/jellycleanarr/config.py | 21 + src/jellycleanarr/main.py | 60 + src/jellycleanarr/models/__init__.py | 0 src/jellycleanarr/models/media.py | 46 + src/jellycleanarr/models/playback.py | 22 + src/jellycleanarr/models/user.py | 29 + src/jellycleanarr/services/__init__.py | 0 src/jellycleanarr/services/stats_service.py | 190 ++ src/jellycleanarr/ui/__init__.py | 0 src/jellycleanarr/ui/app.py | 32 + src/jellycleanarr/ui/screens/__init__.py | 0 src/jellycleanarr/ui/screens/main_screen.py | 76 + src/jellycleanarr/ui/styles/main.tcss | 66 + src/jellycleanarr/ui/widgets/__init__.py | 0 src/jellycleanarr/ui/widgets/library_stats.py | 103 ++ src/jellycleanarr/ui/widgets/series_stats.py | 99 + src/jellycleanarr/ui/widgets/stats_table.py | 45 + src/jellycleanarr/ui/widgets/user_stats.py | 110 ++ src/jellycleanarr/utils/__init__.py | 0 src/jellycleanarr/utils/exceptions.py | 25 + src/jellycleanarr/utils/formatters.py | 39 + styles.css | 263 +++ test_data_flow.py | 96 + test_tui_minimal.py | 42 + test_widget_direct.py | 82 + tests/__init__.py | 0 tests/test_api/__init__.py | 0 tests/test_models/__init__.py | 0 tests/test_services/__init__.py | 0 uv.lock | 3 + 47 files changed, 4502 insertions(+), 1 deletion(-) create mode 100644 .claude/settings.local.json create mode 100644 .domains create mode 100644 .env.example create mode 100644 .gitignore create mode 100644 DEBUGGING_NOTES.md create mode 100644 FIXES.md create mode 100644 TROUBLESHOOTING.md create mode 100644 app.js create mode 100644 index.html create mode 100644 jellycleanarr_data_dump.txt create mode 100644 pyproject.toml create mode 100644 run_with_logging.py create mode 100644 src/jellycleanarr/__init__.py create mode 100644 src/jellycleanarr/__main__.py create mode 100644 src/jellycleanarr/api/__init__.py create mode 100644 src/jellycleanarr/api/client.py create mode 100644 src/jellycleanarr/config.py create mode 100644 src/jellycleanarr/main.py create mode 100644 src/jellycleanarr/models/__init__.py create mode 100644 src/jellycleanarr/models/media.py create mode 100644 src/jellycleanarr/models/playback.py create mode 100644 src/jellycleanarr/models/user.py create mode 100644 src/jellycleanarr/services/__init__.py create mode 100644 src/jellycleanarr/services/stats_service.py create mode 100644 src/jellycleanarr/ui/__init__.py create mode 100644 src/jellycleanarr/ui/app.py create mode 100644 src/jellycleanarr/ui/screens/__init__.py create mode 100644 src/jellycleanarr/ui/screens/main_screen.py create mode 100644 src/jellycleanarr/ui/styles/main.tcss create mode 100644 src/jellycleanarr/ui/widgets/__init__.py create mode 100644 src/jellycleanarr/ui/widgets/library_stats.py create mode 100644 src/jellycleanarr/ui/widgets/series_stats.py create mode 100644 src/jellycleanarr/ui/widgets/stats_table.py create mode 100644 src/jellycleanarr/ui/widgets/user_stats.py create mode 100644 src/jellycleanarr/utils/__init__.py create mode 100644 src/jellycleanarr/utils/exceptions.py create mode 100644 src/jellycleanarr/utils/formatters.py create mode 100644 styles.css create mode 100644 test_data_flow.py create mode 100644 test_tui_minimal.py create mode 100644 test_widget_direct.py create mode 100644 tests/__init__.py create mode 100644 tests/test_api/__init__.py create mode 100644 tests/test_models/__init__.py create mode 100644 tests/test_services/__init__.py create mode 100644 uv.lock diff --git a/.claude/settings.local.json b/.claude/settings.local.json new file mode 100644 index 0000000..845fe1f --- /dev/null +++ b/.claude/settings.local.json @@ -0,0 +1,10 @@ +{ + "permissions": { + "allow": [ + "Bash(source:*)", + "Bash(python -m jellycleanarr:*)", + "Bash(uv run python:*)", + "Bash(.venv/Scripts/python.exe:*)" + ] + } +} diff --git a/.domains b/.domains new file mode 100644 index 0000000..7f0e56e --- /dev/null +++ b/.domains @@ -0,0 +1 @@ +jellycleanarr.hriggs.pages.hudsonriggs.systems diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..cbe3736 --- /dev/null +++ b/.env.example @@ -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 diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..edbca31 --- /dev/null +++ b/.gitignore @@ -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/ \ No newline at end of file diff --git a/DEBUGGING_NOTES.md b/DEBUGGING_NOTES.md new file mode 100644 index 0000000..eb48544 --- /dev/null +++ b/DEBUGGING_NOTES.md @@ -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) diff --git a/FIXES.md b/FIXES.md new file mode 100644 index 0000000..042fa5e --- /dev/null +++ b/FIXES.md @@ -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. diff --git a/README.md b/README.md index ef2aaf2..37cd48c 100644 --- a/README.md +++ b/README.md @@ -1,3 +1,193 @@ # Jellycleanarr -A tui that allows you to see and delete old media \ No newline at end of file +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) \ No newline at end of file diff --git a/TROUBLESHOOTING.md b/TROUBLESHOOTING.md new file mode 100644 index 0000000..aa24ee5 --- /dev/null +++ b/TROUBLESHOOTING.md @@ -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. diff --git a/app.js b/app.js new file mode 100644 index 0000000..90b9b2c --- /dev/null +++ b/app.js @@ -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 = "ListWeight"; + 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 = ` +
+

${item.item}

+ ${item.category} +
+ `; + 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"}
+ 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); diff --git a/index.html b/index.html new file mode 100644 index 0000000..5ad8841 --- /dev/null +++ b/index.html @@ -0,0 +1,77 @@ + + + + + + Opinionated Firearms Spawn List Builder + + + +
+
+

Opinionated Firearms Spawn List Builder

+

Edit firearm/attachment spawn enablement, placement lists, and spawn rates.

+
+
+ + + +
+
+ +
+ Load an extracted catalog JSON to begin. +
+ +
+
+
+

Items

+
+ + + +
+
+
+ + + + + + + + + + +
SpawnItemCategorySpawn Loacation
+
+
+ +
+
+

Selected Item

+ +
+
Select an item to edit placements and spawn rate.
+
+
+ + + + diff --git a/jellycleanarr_data_dump.txt b/jellycleanarr_data_dump.txt new file mode 100644 index 0000000..2753189 --- /dev/null +++ b/jellycleanarr_data_dump.txt @@ -0,0 +1,1591 @@ +Jellycleanarr Data Dump - 2026-02-08 15:46:58.004152 +Jellyfin Server: https://jellyfin.hudsonriggs.systems + + +================================================================================ +1. CONNECTION TEST +================================================================================ +✓ Connection successful + +================================================================================ +2. GET USERS +================================================================================ +Found 12 users + +================================================================================ +SECTION: Users Response +================================================================================ +[ + { + "Name": "akadmin", + "ServerId": "a257b10c831345fe8ea17e4f3e4a01b5", + "Id": "3c312bec172943f8a2527a5aa28e57f4", + "HasPassword": true, + "HasConfiguredPassword": true, + "HasConfiguredEasyPassword": false, + "EnableAutoLogin": false, + "LastActivityDate": "2025-10-06T15:29:59.9196888Z", + "Configuration": { + "PlayDefaultAudioTrack": true, + "SubtitleLanguagePreference": "", + "DisplayMissingEpisodes": false, + "GroupedFolders": [], + "SubtitleMode": "Default", + "DisplayCollectionsView": false, + "EnableLocalPassword": false, + "OrderedViews": [], + "LatestItemsExcludes": [], + "MyMediaExcludes": [], + "HidePlayedInLatest": true, + "RememberAudioSelections": true, + "RememberSubtitleSelections": true, + "EnableNextEpisodeAutoPlay": true, + "CastReceiverId": "F007D354" + }, + "Policy": { + "IsAdministrator": true, + "IsHidden": true, + "EnableCollectionManagement": false, + "EnableSubtitleManagement": false, + "EnableLyricManagement": false, + "IsDisabled": false, + "BlockedTags": [], + "AllowedTags": [], + "EnableUserPreferenceAccess": true, + "AccessSchedules": [], + "BlockUnratedItems": [], + "EnableRemoteControlOfOtherUsers": false, + "EnableSharedDeviceControl": true, + "EnableRemoteAccess": true, + "EnableLiveTvManagement": false, + "EnableLiveTvAccess": false, + "EnableMediaPlayback": true, + "EnableAudioPlaybackTranscoding": true, + "EnableVideoPlaybackTranscoding": true, + "EnablePlaybackRemuxing": true, + "ForceRemoteSourceTranscoding": false, + "EnableContentDeletion": false, + "EnableContentDeletionFromFolders": [], + "EnableContentDownloading": true, + "EnableSyncTranscoding": true, + "EnableMediaConversion": true, + "EnabledDevices": [], + "EnableAllDevices": true, + "EnabledChannels": [], + "EnableAllChannels": true, + "EnabledFolders": [ + "f137a2dd21bbc1b99aa5c0f6bf02a805", + "a656b907eb3a73532e40e44b968d0225" + ], + "EnableAllFolders": false, + "InvalidLoginAttemptCount": 0, + "LoginAttemptsBeforeLockout": -1, + "MaxActiveSessions": 0, + "EnablePublicSharing": true, + "BlockedMediaFolders": [], + "BlockedChannels": [], + "RemoteClientBitrateLimit": 0, + "AuthenticationProviderId": "Jellyfin.Plugin.SSO_Auth.Api.SSOController", + "PasswordResetProviderId": "Jellyfin.Server.Implementations.Users.DefaultPasswordResetProvider", + "SyncPlayAccess": "CreateAndJoinGroups" + } + }, + { + "Name": "Anthonycapit", + "ServerId": "a257b10c831345fe8ea17e4f3e4a01b5", + "Id": "fe8b074420f144eb9f8ca777a36ccb42", + "HasPassword": true, + "HasConfiguredPassword": true, + "HasConfiguredEasyPassword": false, + "EnableAutoLogin": false, + "LastActivityDate": "2026-01-12T14:00:00.465107Z", + "Configuration": { + "PlayDefaultAudioTrack": true, + "SubtitleLanguagePreference": "", + "DisplayMissingEpisodes": false, + "GroupedFolders": [], + "SubtitleMode": "Default", + "DisplayCollectionsView": false, + "EnableLocalPassword": false, + "OrderedViews": [], + "LatestItemsExcludes": [], + "MyMediaExcludes": [], + "HidePlayedInLatest": true, + "RememberAudioSelections": true, + "RememberSubtitleSelections": true, + "EnableNextEpisodeAutoPlay": true, + "CastReceiverId": "F007D354" + }, + "Policy": { + "IsAdministrator": false, + "IsHidden": true, + "EnableCollectionManagement": false, + "EnableSubtitleManagement": false, + "EnableLyricManagement": false, + "IsDisabled": false, + "BlockedTags": [], + "AllowedTags": [], + "EnableUserPreferenceAccess": true, + "AccessSchedules": [], + "BlockUnratedItems": [], + "EnableRemoteControlOfOtherUsers": false, + "EnableSharedDeviceControl": true, + "EnableRemoteAccess": true, + "EnableLiveTvManagement": false, + "EnableLiveTvAccess": false, + "EnableMediaPlayback": true, + "EnableAudioPlaybackTranscoding": true, + "EnableVideoPlaybackTranscoding": true, + "EnablePlaybackRemuxing": true, + "ForceRemoteSourceTranscoding": false, + "EnableContentDeletion": false, + "EnableContentDeletionFromFolders": [], + "EnableContentDownloading": true, + "EnableSyncTranscoding": true, + "EnableMediaConversion": true, + "EnabledDevices": [], + "EnableAllDevices": true, + "EnabledChannels": [], + "EnableAllChannels": true, + "EnabledFolders": [ + "f137a2dd21bbc1b99aa5c0f6bf02a805", + "a656b907eb3a73532e40e44b968d0225" + ], + "EnableAllFolders": false, + "InvalidLoginAttemptCount": 0, + "LoginAttemptsBeforeLockout": -1, + "MaxActiveSessions": 0, + "EnablePublicSharing": true, + "BlockedMediaFolders": [], + "BlockedChannels": [], + "RemoteClientBitrateLimit": 0, + "AuthenticationProviderId": "Jellyfin.Plugin.SSO_Auth.Api.SSOController", + "PasswordResetProviderId": "Jellyfin.Server.Implementations.Users.DefaultPasswordResetProvider", + "SyncPlayAccess": "CreateAndJoinGroups" + } + }, + { + "Name": "Commie", + "ServerId": "a257b10c831345fe8ea17e4f3e4a01b5", + "Id": "5f54d67988a443a5b1a5f8afc587ddf5", + "HasPassword": true, + "HasConfiguredPassword": true, + "HasConfiguredEasyPassword": false, + "EnableAutoLogin": false, + "LastActivityDate": "2026-01-31T01:35:48.1021613Z", + "Configuration": { + "PlayDefaultAudioTrack": true, + "SubtitleLanguagePreference": "", + "DisplayMissingEpisodes": false, + "GroupedFolders": [], + "SubtitleMode": "Default", + "DisplayCollectionsView": false, + "EnableLocalPassword": false, + "OrderedViews": [], + "LatestItemsExcludes": [], + "MyMediaExcludes": [], + "HidePlayedInLatest": true, + "RememberAudioSelections": true, + "RememberSubtitleSelections": true, + "EnableNextEpisodeAutoPlay": true, + "CastReceiverId": "F007D354" + }, + "Policy": { + "IsAdministrator": false, + "IsHidden": true, + "EnableCollectionManagement": false, + "EnableSubtitleManagement": false, + "EnableLyricManagement": false, + "IsDisabled": false, + "BlockedTags": [], + "AllowedTags": [], + "EnableUserPreferenceAccess": true, + "AccessSchedules": [], + "BlockUnratedItems": [], + "EnableRemoteControlOfOtherUsers": false, + "EnableSharedDeviceControl": true, + "EnableRemoteAccess": true, + "EnableLiveTvManagement": false, + "EnableLiveTvAccess": false, + "EnableMediaPlayback": true, + "EnableAudioPlaybackTranscoding": true, + "EnableVideoPlaybackTranscoding": true, + "EnablePlaybackRemuxing": true, + "ForceRemoteSourceTranscoding": false, + "EnableContentDeletion": false, + "EnableContentDeletionFromFolders": [], + "EnableContentDownloading": true, + "EnableSyncTranscoding": true, + "EnableMediaConversion": true, + "EnabledDevices": [], + "EnableAllDevices": true, + "EnabledChannels": [], + "EnableAllChannels": true, + "EnabledFolders": [ + "f137a2dd21bbc1b99aa5c0f6bf02a805", + "a656b907eb3a73532e40e44b968d0225" + ], + "EnableAllFolders": false, + "InvalidLoginAttemptCount": 0, + "LoginAttemptsBeforeLockout": -1, + "MaxActiveSessions": 0, + "EnablePublicSharing": true, + "BlockedMediaFolders": [], + "BlockedChannels": [], + "RemoteClientBitrateLimit": 0, + "AuthenticationProviderId": "Jellyfin.Plugin.SSO_Auth.Api.SSOController", + "PasswordResetProviderId": "Jellyfin.Server.Implementations.Users.DefaultPasswordResetProvider", + "SyncPlayAccess": "CreateAndJoinGroups" + } + }, + { + "Name": "GeorgeFloyd", + "ServerId": "a257b10c831345fe8ea17e4f3e4a01b5", + "Id": "eeaa3b9fd4b449138b01a2db8d730580", + "HasPassword": true, + "HasConfiguredPassword": true, + "HasConfiguredEasyPassword": false, + "EnableAutoLogin": false, + "LastActivityDate": "2026-01-14T02:15:53.9622931Z", + "Configuration": { + "PlayDefaultAudioTrack": true, + "SubtitleLanguagePreference": "", + "DisplayMissingEpisodes": false, + "GroupedFolders": [], + "SubtitleMode": "Default", + "DisplayCollectionsView": false, + "EnableLocalPassword": false, + "OrderedViews": [], + "LatestItemsExcludes": [], + "MyMediaExcludes": [], + "HidePlayedInLatest": true, + "RememberAudioSelections": true, + "RememberSubtitleSelections": true, + "EnableNextEpisodeAutoPlay": true, + "CastReceiverId": "F007D354" + }, + "Policy": { + "IsAdministrator": false, + "IsHidden": true, + "EnableCollectionManagement": false, + "EnableSubtitleManagement": false, + "EnableLyricManagement": false, + "IsDisabled": false, + "BlockedTags": [], + "AllowedTags": [], + "EnableUserPreferenceAccess": true, + "AccessSchedules": [], + "BlockUnratedItems": [], + "EnableRemoteControlOfOtherUsers": false, + "EnableSharedDeviceControl": true, + "EnableRemoteAccess": true, + "EnableLiveTvManagement": false, + "EnableLiveTvAccess": false, + "EnableMediaPlayback": true, + "EnableAudioPlaybackTranscoding": true, + "EnableVideoPlaybackTranscoding": true, + "EnablePlaybackRemuxing": true, + "ForceRemoteSourceTranscoding": false, + "EnableContentDeletion": false, + "EnableContentDeletionFromFolders": [], + "EnableContentDownloading": true, + "EnableSyncTranscoding": true, + "EnableMediaConversion": true, + "EnabledDevices": [], + "EnableAllDevices": true, + "EnabledChannels": [], + "EnableAllChannels": true, + "EnabledFolders": [ + "f137a2dd21bbc1b99aa5c0f6bf02a805", + "a656b907eb3a73532e40e44b968d0225" + ], + "EnableAllFolders": false, + "InvalidLoginAttemptCount": 0, + "LoginAttemptsBeforeLockout": -1, + "MaxActiveSessions": 0, + "EnablePublicSharing": true, + "BlockedMediaFolders": [], + "BlockedChannels": [], + "RemoteClientBitrateLimit": 0, + "AuthenticationProviderId": "Jellyfin.Plugin.SSO_Auth.Api.SSOController", + "PasswordResetProviderId": "Jellyfin.Server.Implementations.Users.DefaultPasswordResetProvider", + "SyncPlayAccess": "CreateAndJoinGroups" + } + }, + { + "Name": "HRiggs", + "ServerId": "a257b10c831345fe8ea17e4f3e4a01b5", + "Id": "2bcf41b6c3d4453a82fdd0a02a31d417", + "PrimaryImageTag": "2c3ff2975a07df2074f426534082014a", + "HasPassword": true, + "HasConfiguredPassword": true, + "HasConfiguredEasyPassword": false, + "EnableAutoLogin": false, + "LastActivityDate": "2026-02-08T20:32:00.4464374Z", + "Configuration": { + "PlayDefaultAudioTrack": true, + "SubtitleLanguagePreference": "", + "DisplayMissingEpisodes": false, + "GroupedFolders": [], + "SubtitleMode": "Default", + "DisplayCollectionsView": false, + "EnableLocalPassword": false, + "OrderedViews": [], + "LatestItemsExcludes": [], + "MyMediaExcludes": [], + "HidePlayedInLatest": true, + "RememberAudioSelections": true, + "RememberSubtitleSelections": true, + "EnableNextEpisodeAutoPlay": true, + "CastReceiverId": "F007D354" + }, + "Policy": { + "IsAdministrator": true, + "IsHidden": true, + "EnableCollectionManagement": false, + "EnableSubtitleManagement": false, + "EnableLyricManagement": false, + "IsDisabled": false, + "BlockedTags": [], + "AllowedTags": [], + "EnableUserPreferenceAccess": true, + "AccessSchedules": [], + "BlockUnratedItems": [], + "EnableRemoteControlOfOtherUsers": false, + "EnableSharedDeviceControl": true, + "EnableRemoteAccess": true, + "EnableLiveTvManagement": false, + "EnableLiveTvAccess": false, + "EnableMediaPlayback": true, + "EnableAudioPlaybackTranscoding": true, + "EnableVideoPlaybackTranscoding": true, + "EnablePlaybackRemuxing": true, + "ForceRemoteSourceTranscoding": false, + "EnableContentDeletion": false, + "EnableContentDeletionFromFolders": [], + "EnableContentDownloading": true, + "EnableSyncTranscoding": true, + "EnableMediaConversion": true, + "EnabledDevices": [], + "EnableAllDevices": true, + "EnabledChannels": [], + "EnableAllChannels": true, + "EnabledFolders": [ + "f137a2dd21bbc1b99aa5c0f6bf02a805", + "a656b907eb3a73532e40e44b968d0225" + ], + "EnableAllFolders": false, + "InvalidLoginAttemptCount": 0, + "LoginAttemptsBeforeLockout": -1, + "MaxActiveSessions": 0, + "EnablePublicSharing": true, + "BlockedMediaFolders": [], + "BlockedChannels": [], + "RemoteClientBitrateLimit": 0, + "AuthenticationProviderId": "Jellyfin.Plugin.SSO_Auth.Api.SSOController", + "PasswordResetProviderId": "Jellyfin.Server.Implementations.Users.DefaultPasswordResetProvider", + "SyncPlayAccess": "CreateAndJoinGroups" + } + }, + { + "Name": "Jarheels", + "ServerId": "a257b10c831345fe8ea17e4f3e4a01b5", + "Id": "1df4dde874274ab1a42f0d59dfbfd36f", + "HasPassword": true, + "HasConfiguredPassword": true, + "HasConfiguredEasyPassword": false, + "EnableAutoLogin": false, + "LastActivityDate": "2025-11-25T23:55:18.1769068Z", + "Configuration": { + "PlayDefaultAudioTrack": true, + "SubtitleLanguagePreference": "", + "DisplayMissingEpisodes": false, + "GroupedFolders": [], + "SubtitleMode": "Default", + "DisplayCollectionsView": false, + "EnableLocalPassword": false, + "OrderedViews": [], + "LatestItemsExcludes": [], + "MyMediaExcludes": [], + "HidePlayedInLatest": true, + "RememberAudioSelections": true, + "RememberSubtitleSelections": true, + "EnableNextEpisodeAutoPlay": true, + "CastReceiverId": "F007D354" + }, + "Policy": { + "IsAdministrator": false, + "IsHidden": true, + "EnableCollectionManagement": false, + "EnableSubtitleManagement": false, + "EnableLyricManagement": false, + "IsDisabled": false, + "BlockedTags": [], + "AllowedTags": [], + "EnableUserPreferenceAccess": true, + "AccessSchedules": [], + "BlockUnratedItems": [], + "EnableRemoteControlOfOtherUsers": false, + "EnableSharedDeviceControl": true, + "EnableRemoteAccess": true, + "EnableLiveTvManagement": false, + "EnableLiveTvAccess": false, + "EnableMediaPlayback": true, + "EnableAudioPlaybackTranscoding": true, + "EnableVideoPlaybackTranscoding": true, + "EnablePlaybackRemuxing": true, + "ForceRemoteSourceTranscoding": false, + "EnableContentDeletion": false, + "EnableContentDeletionFromFolders": [], + "EnableContentDownloading": true, + "EnableSyncTranscoding": true, + "EnableMediaConversion": true, + "EnabledDevices": [], + "EnableAllDevices": true, + "EnabledChannels": [], + "EnableAllChannels": true, + "EnabledFolders": [ + "f137a2dd21bbc1b99aa5c0f6bf02a805", + "a656b907eb3a73532e40e44b968d0225" + ], + "EnableAllFolders": false, + "InvalidLoginAttemptCount": 0, + "LoginAttemptsBeforeLockout": -1, + "MaxActiveSessions": 0, + "EnablePublicSharing": true, + "BlockedMediaFolders": [], + "BlockedChannels": [], + "RemoteClientBitrateLimit": 0, + "AuthenticationProviderId": "Jellyfin.Plugin.SSO_Auth.Api.SSOController", + "PasswordResetProviderId": "Jellyfin.Server.Implementations.Users.DefaultPasswordResetProvider", + "SyncPlayAccess": "CreateAndJoinGroups" + } + }, + { + "Name": "Katenoel", + "ServerId": "a257b10c831345fe8ea17e4f3e4a01b5", + "Id": "8ecb0d020eed42bc88c921d3c1374307", + "HasPassword": true, + "HasConfiguredPassword": true, + "HasConfiguredEasyPassword": false, + "EnableAutoLogin": false, + "LastActivityDate": "2026-02-07T18:06:44.099369Z", + "Configuration": { + "PlayDefaultAudioTrack": true, + "SubtitleLanguagePreference": "", + "DisplayMissingEpisodes": false, + "GroupedFolders": [], + "SubtitleMode": "Default", + "DisplayCollectionsView": false, + "EnableLocalPassword": false, + "OrderedViews": [], + "LatestItemsExcludes": [], + "MyMediaExcludes": [], + "HidePlayedInLatest": true, + "RememberAudioSelections": true, + "RememberSubtitleSelections": true, + "EnableNextEpisodeAutoPlay": true, + "CastReceiverId": "F007D354" + }, + "Policy": { + "IsAdministrator": false, + "IsHidden": true, + "EnableCollectionManagement": false, + "EnableSubtitleManagement": false, + "EnableLyricManagement": false, + "IsDisabled": false, + "BlockedTags": [], + "AllowedTags": [], + "EnableUserPreferenceAccess": true, + "AccessSchedules": [], + "BlockUnratedItems": [], + "EnableRemoteControlOfOtherUsers": false, + "EnableSharedDeviceControl": true, + "EnableRemoteAccess": true, + "EnableLiveTvManagement": false, + "EnableLiveTvAccess": false, + "EnableMediaPlayback": true, + "EnableAudioPlaybackTranscoding": true, + "EnableVideoPlaybackTranscoding": true, + "EnablePlaybackRemuxing": true, + "ForceRemoteSourceTranscoding": false, + "EnableContentDeletion": false, + "EnableContentDeletionFromFolders": [], + "EnableContentDownloading": true, + "EnableSyncTranscoding": true, + "EnableMediaConversion": true, + "EnabledDevices": [], + "EnableAllDevices": true, + "EnabledChannels": [], + "EnableAllChannels": true, + "EnabledFolders": [ + "f137a2dd21bbc1b99aa5c0f6bf02a805", + "a656b907eb3a73532e40e44b968d0225" + ], + "EnableAllFolders": false, + "InvalidLoginAttemptCount": 0, + "LoginAttemptsBeforeLockout": -1, + "MaxActiveSessions": 0, + "EnablePublicSharing": true, + "BlockedMediaFolders": [], + "BlockedChannels": [], + "RemoteClientBitrateLimit": 0, + "AuthenticationProviderId": "Jellyfin.Plugin.SSO_Auth.Api.SSOController", + "PasswordResetProviderId": "Jellyfin.Server.Implementations.Users.DefaultPasswordResetProvider", + "SyncPlayAccess": "CreateAndJoinGroups" + } + }, + { + "Name": "Liddia11", + "ServerId": "a257b10c831345fe8ea17e4f3e4a01b5", + "Id": "650a4e36b813440386b689885c30410c", + "HasPassword": true, + "HasConfiguredPassword": true, + "HasConfiguredEasyPassword": false, + "EnableAutoLogin": false, + "LastActivityDate": "2026-01-04T02:25:51.3799519Z", + "Configuration": { + "PlayDefaultAudioTrack": true, + "SubtitleLanguagePreference": "", + "DisplayMissingEpisodes": false, + "GroupedFolders": [], + "SubtitleMode": "Default", + "DisplayCollectionsView": false, + "EnableLocalPassword": false, + "OrderedViews": [], + "LatestItemsExcludes": [], + "MyMediaExcludes": [], + "HidePlayedInLatest": true, + "RememberAudioSelections": true, + "RememberSubtitleSelections": true, + "EnableNextEpisodeAutoPlay": true, + "CastReceiverId": "F007D354" + }, + "Policy": { + "IsAdministrator": false, + "IsHidden": true, + "EnableCollectionManagement": false, + "EnableSubtitleManagement": false, + "EnableLyricManagement": false, + "IsDisabled": false, + "BlockedTags": [], + "AllowedTags": [], + "EnableUserPreferenceAccess": true, + "AccessSchedules": [], + "BlockUnratedItems": [], + "EnableRemoteControlOfOtherUsers": false, + "EnableSharedDeviceControl": true, + "EnableRemoteAccess": true, + "EnableLiveTvManagement": false, + "EnableLiveTvAccess": false, + "EnableMediaPlayback": true, + "EnableAudioPlaybackTranscoding": true, + "EnableVideoPlaybackTranscoding": true, + "EnablePlaybackRemuxing": true, + "ForceRemoteSourceTranscoding": false, + "EnableContentDeletion": false, + "EnableContentDeletionFromFolders": [], + "EnableContentDownloading": true, + "EnableSyncTranscoding": true, + "EnableMediaConversion": true, + "EnabledDevices": [], + "EnableAllDevices": true, + "EnabledChannels": [], + "EnableAllChannels": true, + "EnabledFolders": [ + "f137a2dd21bbc1b99aa5c0f6bf02a805", + "a656b907eb3a73532e40e44b968d0225" + ], + "EnableAllFolders": false, + "InvalidLoginAttemptCount": 0, + "LoginAttemptsBeforeLockout": -1, + "MaxActiveSessions": 0, + "EnablePublicSharing": true, + "BlockedMediaFolders": [], + "BlockedChannels": [], + "RemoteClientBitrateLimit": 0, + "AuthenticationProviderId": "Jellyfin.Plugin.SSO_Auth.Api.SSOController", + "PasswordResetProviderId": "Jellyfin.Server.Implementations.Users.DefaultPasswordResetProvider", + "SyncPlayAccess": "CreateAndJoinGroups" + } + }, + { + "Name": "Panos", + "ServerId": "a257b10c831345fe8ea17e4f3e4a01b5", + "Id": "35ca50e47ce649a6818a5a0437860e70", + "HasPassword": true, + "HasConfiguredPassword": true, + "HasConfiguredEasyPassword": false, + "EnableAutoLogin": false, + "LastActivityDate": "2026-01-26T00:57:36.5184168Z", + "Configuration": { + "PlayDefaultAudioTrack": true, + "SubtitleLanguagePreference": "", + "DisplayMissingEpisodes": false, + "GroupedFolders": [], + "SubtitleMode": "Default", + "DisplayCollectionsView": false, + "EnableLocalPassword": false, + "OrderedViews": [], + "LatestItemsExcludes": [], + "MyMediaExcludes": [], + "HidePlayedInLatest": true, + "RememberAudioSelections": true, + "RememberSubtitleSelections": true, + "EnableNextEpisodeAutoPlay": true, + "CastReceiverId": "F007D354" + }, + "Policy": { + "IsAdministrator": false, + "IsHidden": true, + "EnableCollectionManagement": false, + "EnableSubtitleManagement": false, + "EnableLyricManagement": false, + "IsDisabled": false, + "BlockedTags": [], + "AllowedTags": [], + "EnableUserPreferenceAccess": true, + "AccessSchedules": [], + "BlockUnratedItems": [], + "EnableRemoteControlOfOtherUsers": false, + "EnableSharedDeviceControl": true, + "EnableRemoteAccess": true, + "EnableLiveTvManagement": false, + "EnableLiveTvAccess": false, + "EnableMediaPlayback": true, + "EnableAudioPlaybackTranscoding": true, + "EnableVideoPlaybackTranscoding": true, + "EnablePlaybackRemuxing": true, + "ForceRemoteSourceTranscoding": false, + "EnableContentDeletion": false, + "EnableContentDeletionFromFolders": [], + "EnableContentDownloading": true, + "EnableSyncTranscoding": true, + "EnableMediaConversion": true, + "EnabledDevices": [], + "EnableAllDevices": true, + "EnabledChannels": [], + "EnableAllChannels": true, + "EnabledFolders": [ + "f137a2dd21bbc1b99aa5c0f6bf02a805", + "a656b907eb3a73532e40e44b968d0225" + ], + "EnableAllFolders": false, + "InvalidLoginAttemptCount": 0, + "LoginAttemptsBeforeLockout": -1, + "MaxActiveSessions": 0, + "EnablePublicSharing": true, + "BlockedMediaFolders": [], + "BlockedChannels": [], + "RemoteClientBitrateLimit": 0, + "AuthenticationProviderId": "Jellyfin.Plugin.SSO_Auth.Api.SSOController", + "PasswordResetProviderId": "Jellyfin.Server.Implementations.Users.DefaultPasswordResetProvider", + "SyncPlayAccess": "CreateAndJoinGroups" + } + }, + { + "Name": "Rafe", + "ServerId": "a257b10c831345fe8ea17e4f3e4a01b5", + "Id": "84ac9ffc26dd4de3b2fcf9e22bc18829", + "HasPassword": true, + "HasConfiguredPassword": true, + "HasConfiguredEasyPassword": false, + "EnableAutoLogin": false, + "LastActivityDate": "2025-12-26T21:03:56.5795725Z", + "Configuration": { + "PlayDefaultAudioTrack": true, + "SubtitleLanguagePreference": "", + "DisplayMissingEpisodes": false, + "GroupedFolders": [], + "SubtitleMode": "Default", + "DisplayCollectionsView": false, + "EnableLocalPassword": false, + "OrderedViews": [], + "LatestItemsExcludes": [], + "MyMediaExcludes": [], + "HidePlayedInLatest": true, + "RememberAudioSelections": true, + "RememberSubtitleSelections": true, + "EnableNextEpisodeAutoPlay": true, + "CastReceiverId": "F007D354" + }, + "Policy": { + "IsAdministrator": false, + "IsHidden": true, + "EnableCollectionManagement": false, + "EnableSubtitleManagement": false, + "EnableLyricManagement": false, + "IsDisabled": false, + "BlockedTags": [], + "AllowedTags": [], + "EnableUserPreferenceAccess": true, + "AccessSchedules": [], + "BlockUnratedItems": [], + "EnableRemoteControlOfOtherUsers": false, + "EnableSharedDeviceControl": true, + "EnableRemoteAccess": true, + "EnableLiveTvManagement": false, + "EnableLiveTvAccess": false, + "EnableMediaPlayback": true, + "EnableAudioPlaybackTranscoding": true, + "EnableVideoPlaybackTranscoding": true, + "EnablePlaybackRemuxing": true, + "ForceRemoteSourceTranscoding": false, + "EnableContentDeletion": false, + "EnableContentDeletionFromFolders": [], + "EnableContentDownloading": true, + "EnableSyncTranscoding": true, + "EnableMediaConversion": true, + "EnabledDevices": [], + "EnableAllDevices": true, + "EnabledChannels": [], + "EnableAllChannels": true, + "EnabledFolders": [ + "f137a2dd21bbc1b99aa5c0f6bf02a805", + "a656b907eb3a73532e40e44b968d0225" + ], + "EnableAllFolders": false, + "InvalidLoginAttemptCount": 1, + "LoginAttemptsBeforeLockout": -1, + "MaxActiveSessions": 0, + "EnablePublicSharing": true, + "BlockedMediaFolders": [], + "BlockedChannels": [], + "RemoteClientBitrateLimit": 0, + "AuthenticationProviderId": "Jellyfin.Plugin.SSO_Auth.Api.SSOController", + "PasswordResetProviderId": "Jellyfin.Server.Implementations.Users.DefaultPasswordResetProvider", + "SyncPlayAccess": "CreateAndJoinGroups" + } + }, + { + "Name": "root", + "ServerId": "a257b10c831345fe8ea17e4f3e4a01b5", + "Id": "a505c8cf5a3a4d8ca020eec62471a9ae", + "HasPassword": true, + "HasConfiguredPassword": true, + "HasConfiguredEasyPassword": false, + "EnableAutoLogin": false, + "LastLoginDate": "2025-10-03T17:28:07.7036461Z", + "LastActivityDate": "2025-10-03T17:28:07.7036461Z", + "Configuration": { + "PlayDefaultAudioTrack": true, + "SubtitleLanguagePreference": "", + "DisplayMissingEpisodes": false, + "GroupedFolders": [], + "SubtitleMode": "Default", + "DisplayCollectionsView": false, + "EnableLocalPassword": false, + "OrderedViews": [], + "LatestItemsExcludes": [], + "MyMediaExcludes": [], + "HidePlayedInLatest": true, + "RememberAudioSelections": true, + "RememberSubtitleSelections": true, + "EnableNextEpisodeAutoPlay": true, + "CastReceiverId": "F007D354" + }, + "Policy": { + "IsAdministrator": true, + "IsHidden": true, + "EnableCollectionManagement": true, + "EnableSubtitleManagement": true, + "EnableLyricManagement": false, + "IsDisabled": false, + "BlockedTags": [], + "AllowedTags": [], + "EnableUserPreferenceAccess": true, + "AccessSchedules": [], + "BlockUnratedItems": [], + "EnableRemoteControlOfOtherUsers": true, + "EnableSharedDeviceControl": true, + "EnableRemoteAccess": true, + "EnableLiveTvManagement": true, + "EnableLiveTvAccess": true, + "EnableMediaPlayback": true, + "EnableAudioPlaybackTranscoding": true, + "EnableVideoPlaybackTranscoding": true, + "EnablePlaybackRemuxing": true, + "ForceRemoteSourceTranscoding": false, + "EnableContentDeletion": true, + "EnableContentDeletionFromFolders": [], + "EnableContentDownloading": true, + "EnableSyncTranscoding": true, + "EnableMediaConversion": true, + "EnabledDevices": [], + "EnableAllDevices": true, + "EnabledChannels": [], + "EnableAllChannels": false, + "EnabledFolders": [], + "EnableAllFolders": true, + "InvalidLoginAttemptCount": 0, + "LoginAttemptsBeforeLockout": -1, + "MaxActiveSessions": 0, + "EnablePublicSharing": true, + "BlockedMediaFolders": [], + "BlockedChannels": [], + "RemoteClientBitrateLimit": 0, + "AuthenticationProviderId": "Jellyfin.Server.Implementations.Users.DefaultAuthenticationProvider", + "PasswordResetProviderId": "Jellyfin.Server.Implementations.Users.DefaultPasswordResetProvider", + "SyncPlayAccess": "CreateAndJoinGroups" + } + }, + { + "Name": "TV", + "ServerId": "a257b10c831345fe8ea17e4f3e4a01b5", + "Id": "162d8515792e4acbbd7ce2e65e4e4e92", + "HasPassword": true, + "HasConfiguredPassword": true, + "HasConfiguredEasyPassword": false, + "EnableAutoLogin": false, + "LastLoginDate": "2026-01-04T02:31:19.1141865Z", + "LastActivityDate": "2026-02-08T08:13:55.2473933Z", + "Configuration": { + "PlayDefaultAudioTrack": true, + "SubtitleLanguagePreference": "", + "DisplayMissingEpisodes": false, + "GroupedFolders": [], + "SubtitleMode": "Default", + "DisplayCollectionsView": false, + "EnableLocalPassword": false, + "OrderedViews": [], + "LatestItemsExcludes": [], + "MyMediaExcludes": [], + "HidePlayedInLatest": true, + "RememberAudioSelections": true, + "RememberSubtitleSelections": true, + "EnableNextEpisodeAutoPlay": true, + "CastReceiverId": "F007D354" + }, + "Policy": { + "IsAdministrator": false, + "IsHidden": true, + "EnableCollectionManagement": false, + "EnableSubtitleManagement": false, + "EnableLyricManagement": false, + "IsDisabled": false, + "BlockedTags": [], + "AllowedTags": [], + "EnableUserPreferenceAccess": true, + "AccessSchedules": [], + "BlockUnratedItems": [], + "EnableRemoteControlOfOtherUsers": false, + "EnableSharedDeviceControl": true, + "EnableRemoteAccess": true, + "EnableLiveTvManagement": true, + "EnableLiveTvAccess": true, + "EnableMediaPlayback": true, + "EnableAudioPlaybackTranscoding": true, + "EnableVideoPlaybackTranscoding": true, + "EnablePlaybackRemuxing": true, + "ForceRemoteSourceTranscoding": false, + "EnableContentDeletion": false, + "EnableContentDeletionFromFolders": [], + "EnableContentDownloading": true, + "EnableSyncTranscoding": true, + "EnableMediaConversion": true, + "EnabledDevices": [], + "EnableAllDevices": true, + "EnabledChannels": [], + "EnableAllChannels": false, + "EnabledFolders": [ + "f137a2dd21bbc1b99aa5c0f6bf02a805", + "a656b907eb3a73532e40e44b968d0225" + ], + "EnableAllFolders": false, + "InvalidLoginAttemptCount": 0, + "LoginAttemptsBeforeLockout": -1, + "MaxActiveSessions": 0, + "EnablePublicSharing": true, + "BlockedMediaFolders": [], + "BlockedChannels": [], + "RemoteClientBitrateLimit": 0, + "AuthenticationProviderId": "Jellyfin.Server.Implementations.Users.DefaultAuthenticationProvider", + "PasswordResetProviderId": "Jellyfin.Server.Implementations.Users.DefaultPasswordResetProvider", + "SyncPlayAccess": "CreateAndJoinGroups" + } + } +] + +================================================================================ +3. GET LIBRARIES +================================================================================ +Found 3 libraries + +================================================================================ +SECTION: Libraries Response +================================================================================ +[ + { + "Name": "Movies", + "ServerId": "a257b10c831345fe8ea17e4f3e4a01b5", + "Id": "f137a2dd21bbc1b99aa5c0f6bf02a805", + "Etag": "f9c9810983cbb79e4e4cd7ce55739188", + "DateCreated": "2025-10-22T15:40:52.4547578Z", + "CanDelete": false, + "CanDownload": false, + "SortName": "movies", + "ExternalUrls": [], + "Path": "/var/lib/jellyfin/root/default/Movies", + "EnableMediaSourceDisplay": true, + "ChannelId": null, + "Taglines": [], + "Genres": [], + "RemoteTrailers": [], + "ProviderIds": {}, + "IsFolder": true, + "ParentId": "e9d5075a555c1cbc394eec4cef295274", + "Type": "CollectionFolder", + "People": [], + "Studios": [], + "GenreItems": [], + "LocalTrailerCount": 0, + "SpecialFeatureCount": 0, + "DisplayPreferencesId": "f137a2dd21bbc1b99aa5c0f6bf02a805", + "Tags": [], + "PrimaryImageAspectRatio": 1.7777777777777777, + "CollectionType": "movies", + "ImageTags": { + "Primary": "bed9dc726432f239835a285d299166d4" + }, + "BackdropImageTags": [], + "ImageBlurHashes": { + "Primary": { + "bed9dc726432f239835a285d299166d4": "WBB3pMkC9FWA9Ga{xuofIVWBt7fk00WB%gkCxtjtV@axt8ozjFWB" + } + }, + "LocationType": "FileSystem", + "MediaType": "Unknown", + "LockedFields": [], + "LockData": false + }, + { + "Name": "Playlists", + "ServerId": "a257b10c831345fe8ea17e4f3e4a01b5", + "Id": "1071671e7bffa0532e930debee501d2e", + "Etag": "8f2bf7ca5361f99d215be388423f7607", + "DateCreated": "0001-01-01T00:00:00.0000000Z", + "CanDelete": false, + "CanDownload": false, + "SortName": "playlists", + "ExternalUrls": [], + "Path": "/var/lib/jellyfin/data/playlists", + "EnableMediaSourceDisplay": true, + "ChannelId": null, + "Taglines": [], + "Genres": [], + "RemoteTrailers": [], + "ProviderIds": {}, + "IsFolder": true, + "ParentId": "f27caa37e5142225cceded48f6553502", + "Type": "ManualPlaylistsFolder", + "People": [], + "Studios": [], + "GenreItems": [], + "LocalTrailerCount": 0, + "SpecialFeatureCount": 0, + "DisplayPreferencesId": "acd905fc82f256d4facbd049e05d1b45", + "Tags": [], + "CollectionType": "playlists", + "ImageTags": {}, + "BackdropImageTags": [], + "ImageBlurHashes": {}, + "LocationType": "FileSystem", + "MediaType": "Unknown", + "LockedFields": [], + "LockData": false + }, + { + "Name": "Shows", + "ServerId": "a257b10c831345fe8ea17e4f3e4a01b5", + "Id": "a656b907eb3a73532e40e44b968d0225", + "Etag": "3ad5dfebc32824791471579fd8742632", + "DateCreated": "2025-10-22T15:41:30.6693935Z", + "CanDelete": false, + "CanDownload": false, + "SortName": "shows", + "ExternalUrls": [], + "Path": "/var/lib/jellyfin/root/default/Shows", + "EnableMediaSourceDisplay": true, + "ChannelId": null, + "Taglines": [], + "Genres": [], + "RemoteTrailers": [], + "ProviderIds": {}, + "IsFolder": true, + "ParentId": "e9d5075a555c1cbc394eec4cef295274", + "Type": "CollectionFolder", + "People": [], + "Studios": [], + "GenreItems": [], + "LocalTrailerCount": 0, + "SpecialFeatureCount": 0, + "DisplayPreferencesId": "a656b907eb3a73532e40e44b968d0225", + "Tags": [], + "PrimaryImageAspectRatio": 1.7777777777777777, + "CollectionType": "tvshows", + "ImageTags": { + "Primary": "d8b7c409032429e81bdc9d56791814e0" + }, + "BackdropImageTags": [], + "ImageBlurHashes": { + "Primary": { + "d8b7c409032429e81bdc9d56791814e0": "WA6*amg24mnOxtWVs;bHWCjEjZbH00jG?bbcM|oLNGjct5bbjbf5" + } + }, + "LocationType": "FileSystem", + "MediaType": "Unknown", + "LockedFields": [], + "LockData": false + } +] + +================================================================================ +4. RAW PLAYBACK QUERY TEST +================================================================================ +Query: SELECT * FROM PlaybackActivity LIMIT 5 +Results: 5 rows + +================================================================================ +SECTION: Raw Playback Query Results +================================================================================ +[ + { + "DateCreated": "2025-11-08 00:07:20.4429661", + "UserId": "2bcf41b6c3d4453a82fdd0a02a31d417", + "ItemId": "40681a93717e0b4869f5a2a06d90a059", + "ItemType": "Episode", + "ItemName": "The Newsroom - s03e03 - Main Justice", + "PlaybackMethod": "Transcode (v:h264 a:aac)", + "ClientName": "Jellyfin Web", + "DeviceName": "Firefox", + "PlayDuration": "218" + }, + { + "DateCreated": "2025-11-08 05:47:11.6717518", + "UserId": "2bcf41b6c3d4453a82fdd0a02a31d417", + "ItemId": "faf6f82ed5f2739b05cb0aa3463ac1c4", + "ItemType": "Episode", + "ItemName": "The Newsroom - s03e04 - Contempt", + "PlaybackMethod": "Transcode (v:direct a:direct)", + "ClientName": "Jellyfin Mobile (iOS)", + "DeviceName": "iPhone", + "PlayDuration": "3421" + }, + { + "DateCreated": "2025-11-08 06:44:13.9701392", + "UserId": "2bcf41b6c3d4453a82fdd0a02a31d417", + "ItemId": "47e6850f5181e4e4af2913366cc53b0a", + "ItemType": "Episode", + "ItemName": "The Newsroom - s03e05 - Oh Shenandoah", + "PlaybackMethod": "Transcode (v:direct a:direct)", + "ClientName": "Jellyfin Mobile (iOS)", + "DeviceName": "iPhone", + "PlayDuration": "338" + }, + { + "DateCreated": "2025-11-08 06:49:58.1957822", + "UserId": "2bcf41b6c3d4453a82fdd0a02a31d417", + "ItemId": "40681a93717e0b4869f5a2a06d90a059", + "ItemType": "Episode", + "ItemName": "The Newsroom - s03e03 - Main Justice", + "PlaybackMethod": "Transcode (v:direct a:direct)", + "ClientName": "Jellyfin Mobile (iOS)", + "DeviceName": "iPhone", + "PlayDuration": "1338" + }, + { + "DateCreated": "2025-11-08 06:57:36.537004", + "UserId": "1df4dde874274ab1a42f0d59dfbfd36f", + "ItemId": "8cb174c11aee2a36a731bf0314aef404", + "ItemType": "Movie", + "ItemName": "Star Wars: Episode I - The Phantom Menace", + "PlaybackMethod": "DirectPlay", + "ClientName": "Jellyfin Web", + "DeviceName": "Chrome", + "PlayDuration": "5379" + } +] + +================================================================================ +5. USER STATS QUERY +================================================================================ +Results: 7 rows + +================================================================================ +SECTION: User Stats Query Results +================================================================================ +[ + { + "UserId": "2bcf41b6c3d4453a82fdd0a02a31d417", + "total_plays": "346", + "total_duration": "-8589444652", + "unique_items": "153", + "last_played": "2026-02-07 21:50:14.7493187" + }, + { + "UserId": "162d8515792e4acbbd7ce2e65e4e4e92", + "total_plays": "204", + "total_duration": "436592", + "unique_items": "115", + "last_played": "2026-02-08 08:06:46.367836" + }, + { + "UserId": "8ecb0d020eed42bc88c921d3c1374307", + "total_plays": "111", + "total_duration": "299605", + "unique_items": "69", + "last_played": "2026-02-07 15:45:21.8831354" + }, + { + "UserId": "650a4e36b813440386b689885c30410c", + "total_plays": "94", + "total_duration": "206849", + "unique_items": "63", + "last_played": "2026-01-04 02:26:00.9378049" + }, + { + "UserId": "35ca50e47ce649a6818a5a0437860e70", + "total_plays": "76", + "total_duration": "157703", + "unique_items": "57", + "last_played": "2026-01-21 02:14:24.0283056" + }, + { + "UserId": "1df4dde874274ab1a42f0d59dfbfd36f", + "total_plays": "11", + "total_duration": "66169", + "unique_items": "5", + "last_played": "2025-11-25 23:48:48.1672816" + }, + { + "UserId": "eeaa3b9fd4b449138b01a2db8d730580", + "total_plays": "4", + "total_duration": "8400", + "unique_items": "3", + "last_played": "2025-12-29 16:48:37.0040525" + } +] + +================================================================================ +6. USER STATS SERVICE +================================================================================ +Most active: 7 users +Least active: 7 users + +================================================================================ +SECTION: Most Active Users +================================================================================ +[ + { + "user_id": "2bcf41b6c3d4453a82fdd0a02a31d417", + "username": "HRiggs", + "total_plays": 346, + "total_duration_seconds": -8589444652, + "unique_items_played": 153, + "last_played": "2026-02-07 21:50:14.749318" + }, + { + "user_id": "162d8515792e4acbbd7ce2e65e4e4e92", + "username": "TV", + "total_plays": 204, + "total_duration_seconds": 436592, + "unique_items_played": 115, + "last_played": "2026-02-08 08:06:46.367836" + }, + { + "user_id": "8ecb0d020eed42bc88c921d3c1374307", + "username": "Katenoel", + "total_plays": 111, + "total_duration_seconds": 299605, + "unique_items_played": 69, + "last_played": "2026-02-07 15:45:21.883135" + }, + { + "user_id": "650a4e36b813440386b689885c30410c", + "username": "Liddia11", + "total_plays": 94, + "total_duration_seconds": 206849, + "unique_items_played": 63, + "last_played": "2026-01-04 02:26:00.937804" + }, + { + "user_id": "35ca50e47ce649a6818a5a0437860e70", + "username": "Panos", + "total_plays": 76, + "total_duration_seconds": 157703, + "unique_items_played": 57, + "last_played": "2026-01-21 02:14:24.028305" + }, + { + "user_id": "1df4dde874274ab1a42f0d59dfbfd36f", + "username": "Jarheels", + "total_plays": 11, + "total_duration_seconds": 66169, + "unique_items_played": 5, + "last_played": "2025-11-25 23:48:48.167281" + }, + { + "user_id": "eeaa3b9fd4b449138b01a2db8d730580", + "username": "GeorgeFloyd", + "total_plays": 4, + "total_duration_seconds": 8400, + "unique_items_played": 3, + "last_played": "2025-12-29 16:48:37.004052" + } +] + +================================================================================ +SECTION: Least Active Users +================================================================================ +[ + { + "user_id": "eeaa3b9fd4b449138b01a2db8d730580", + "username": "GeorgeFloyd", + "total_plays": 4, + "total_duration_seconds": 8400, + "unique_items_played": 3, + "last_played": "2025-12-29 16:48:37.004052" + }, + { + "user_id": "1df4dde874274ab1a42f0d59dfbfd36f", + "username": "Jarheels", + "total_plays": 11, + "total_duration_seconds": 66169, + "unique_items_played": 5, + "last_played": "2025-11-25 23:48:48.167281" + }, + { + "user_id": "35ca50e47ce649a6818a5a0437860e70", + "username": "Panos", + "total_plays": 76, + "total_duration_seconds": 157703, + "unique_items_played": 57, + "last_played": "2026-01-21 02:14:24.028305" + }, + { + "user_id": "650a4e36b813440386b689885c30410c", + "username": "Liddia11", + "total_plays": 94, + "total_duration_seconds": 206849, + "unique_items_played": 63, + "last_played": "2026-01-04 02:26:00.937804" + }, + { + "user_id": "8ecb0d020eed42bc88c921d3c1374307", + "username": "Katenoel", + "total_plays": 111, + "total_duration_seconds": 299605, + "unique_items_played": 69, + "last_played": "2026-02-07 15:45:21.883135" + }, + { + "user_id": "162d8515792e4acbbd7ce2e65e4e4e92", + "username": "TV", + "total_plays": 204, + "total_duration_seconds": 436592, + "unique_items_played": 115, + "last_played": "2026-02-08 08:06:46.367836" + }, + { + "user_id": "2bcf41b6c3d4453a82fdd0a02a31d417", + "username": "HRiggs", + "total_plays": 346, + "total_duration_seconds": -8589444652, + "unique_items_played": 153, + "last_played": "2026-02-07 21:50:14.749318" + } +] + +================================================================================ +7. SERIES STATS SERVICE +================================================================================ +Most watched: 10 items +Least watched: 10 items + +================================================================================ +SECTION: Most Watched Series +================================================================================ +[ + { + "series_id": "dbc9a46cc068dd6d253b0c4d259a754a", + "series_name": "Landman - s02e05 - The Pirate Dinner", + "total_plays": 13, + "total_duration_seconds": 13281, + "unique_users": 3, + "episode_count": 0 + }, + { + "series_id": "bd15803031a9722cb1d1d545d5f5b8ba", + "series_name": "Skyfall", + "total_plays": 12, + "total_duration_seconds": -4294957381, + "unique_users": 2, + "episode_count": 0 + }, + { + "series_id": "6ba29056a7fe8c09d0dc96eaf9424ad9", + "series_name": "Greenland", + "total_plays": 11, + "total_duration_seconds": 8213, + "unique_users": 2, + "episode_count": 0 + }, + { + "series_id": "0b12357a1c100f5ddd04c6c9f9dff1ba", + "series_name": "Wicked: For Good", + "total_plays": 11, + "total_duration_seconds": 37335, + "unique_users": 2, + "episode_count": 0 + }, + { + "series_id": "b5d4bb82d20a8e4fc61889c687195c78", + "series_name": "The Beast in Me - s01e05 - Bacchanal", + "total_plays": 9, + "total_duration_seconds": 6232, + "unique_users": 2, + "episode_count": 0 + }, + { + "series_id": "545d436f15cae93374fb892a19566068", + "series_name": "The Beast in Me - s01e04 - Thanatos", + "total_plays": 9, + "total_duration_seconds": 4926, + "unique_users": 2, + "episode_count": 0 + }, + { + "series_id": "e8eeb764175754cc233e9b85b8e42b0d", + "series_name": "Homestead", + "total_plays": 8, + "total_duration_seconds": 6302, + "unique_users": 1, + "episode_count": 0 + }, + { + "series_id": "e8a77f51ffa9a29df3f04c02793f651f", + "series_name": "Down Periscope", + "total_plays": 8, + "total_duration_seconds": 10731, + "unique_users": 2, + "episode_count": 0 + }, + { + "series_id": "995a6a841bc84d5d3dfde6c2cb556e53", + "series_name": "Landman - s02e02 - Sins of the Father", + "total_plays": 8, + "total_duration_seconds": 13866, + "unique_users": 4, + "episode_count": 0 + }, + { + "series_id": "8b7d29eeee137545b55ac3c9a8754f0d", + "series_name": "Black Bird - s01e03 - Hand to Mouth", + "total_plays": 8, + "total_duration_seconds": -2147475547, + "unique_users": 2, + "episode_count": 0 + } +] + +================================================================================ +SECTION: Least Watched Series +================================================================================ +[ + { + "series_id": "fae2d77cedb40b71eee072ea7b800939", + "series_name": "Prison Break - s01e02 - Allen", + "total_plays": 1, + "total_duration_seconds": 2593, + "unique_users": 1, + "episode_count": 0 + }, + { + "series_id": "f891920acacc4b5060ee5606fe543540", + "series_name": "Quantum of Solace", + "total_plays": 1, + "total_duration_seconds": 6683, + "unique_users": 1, + "episode_count": 0 + }, + { + "series_id": "f7d1efb1db2d8ebe3116db7d9c1f68b4", + "series_name": "The Santa Clause", + "total_plays": 1, + "total_duration_seconds": 289, + "unique_users": 1, + "episode_count": 0 + }, + { + "series_id": "f6efcb16b61ad140d539c7121df85b17", + "series_name": "Love Island USA: Laid Bare - s05e05 - Unseen Bits", + "total_plays": 1, + "total_duration_seconds": 2380, + "unique_users": 1, + "episode_count": 0 + }, + { + "series_id": "f44bd4fcd989f5880cbdd9ba6832e865", + "series_name": "Ronin", + "total_plays": 1, + "total_duration_seconds": 154, + "unique_users": 1, + "episode_count": 0 + }, + { + "series_id": "f380d973cfacb576d5a066cd1c889358", + "series_name": "Love Island USA: Laid Bare - s05e07 - Episode #5.7", + "total_plays": 1, + "total_duration_seconds": 3174, + "unique_users": 1, + "episode_count": 0 + }, + { + "series_id": "f2e26aa5bfa00c23235a20b96224707d", + "series_name": "Prison Break - s01e07 - Riots, Drills and the Devil (2)", + "total_plays": 1, + "total_duration_seconds": 2404, + "unique_users": 1, + "episode_count": 0 + }, + { + "series_id": "f2d677d7254a33ff7ee15cc3d0ad27b6", + "series_name": "Top Guns: The Next Generation - s01e06 - Last Chance", + "total_plays": 1, + "total_duration_seconds": 2400, + "unique_users": 1, + "episode_count": 0 + }, + { + "series_id": "f01ef01e0eca0027a8996ecbb9e110e2", + "series_name": "Top Gear - s18e02 - Episode 2", + "total_plays": 1, + "total_duration_seconds": 1101, + "unique_users": 1, + "episode_count": 0 + }, + { + "series_id": "efbb4b036e544dc3d3728eb2ca990864", + "series_name": "Top Gear - s18e01 - Episode 1", + "total_plays": 1, + "total_duration_seconds": 3748, + "unique_users": 1, + "episode_count": 0 + } +] + +================================================================================ +8. LIBRARY STATS SERVICE +================================================================================ +Most active: 3 libraries +Least active: 3 libraries + +================================================================================ +SECTION: Most Active Libraries +================================================================================ +[ + { + "library_id": "f137a2dd21bbc1b99aa5c0f6bf02a805", + "library_name": "Movies", + "total_plays": 282, + "total_duration_seconds": -2862756445, + "unique_users": 7, + "item_count": 123 + }, + { + "library_id": "1071671e7bffa0532e930debee501d2e", + "library_name": "Playlists", + "total_plays": 282, + "total_duration_seconds": -2862756445, + "unique_users": 7, + "item_count": 123 + }, + { + "library_id": "a656b907eb3a73532e40e44b968d0225", + "library_name": "Shows", + "total_plays": 282, + "total_duration_seconds": -2862756445, + "unique_users": 7, + "item_count": 123 + } +] + +================================================================================ +SECTION: Least Active Libraries +================================================================================ +[ + { + "library_id": "f137a2dd21bbc1b99aa5c0f6bf02a805", + "library_name": "Movies", + "total_plays": 282, + "total_duration_seconds": -2862756445, + "unique_users": 7, + "item_count": 123 + }, + { + "library_id": "1071671e7bffa0532e930debee501d2e", + "library_name": "Playlists", + "total_plays": 282, + "total_duration_seconds": -2862756445, + "unique_users": 7, + "item_count": 123 + }, + { + "library_id": "a656b907eb3a73532e40e44b968d0225", + "library_name": "Shows", + "total_plays": 282, + "total_duration_seconds": -2862756445, + "unique_users": 7, + "item_count": 123 + } +] + +================================================================================ +9. SIMULATED TUI TABLE DATA +================================================================================ + +--- USER STATS TABLES --- + +Most Active Users Table Rows: + Row 1: ['HRiggs', '346', '3h 9m', '153'] + Row 2: ['TV', '204', '5d 1h 16m', '115'] + Row 3: ['Katenoel', '111', '3d 11h 13m', '69'] + Row 4: ['Liddia11', '94', '2d 9h 27m', '63'] + Row 5: ['Panos', '76', '1d 19h 48m', '57'] + Row 6: ['Jarheels', '11', '18h 22m', '5'] + Row 7: ['GeorgeFloyd', '4', '2h 20m', '3'] + +Least Active Users Table Rows: + Row 1: ['GeorgeFloyd', '4', '2h 20m', '3'] + Row 2: ['Jarheels', '11', '18h 22m', '5'] + Row 3: ['Panos', '76', '1d 19h 48m', '57'] + Row 4: ['Liddia11', '94', '2d 9h 27m', '63'] + Row 5: ['Katenoel', '111', '3d 11h 13m', '69'] + Row 6: ['TV', '204', '5d 1h 16m', '115'] + Row 7: ['HRiggs', '346', '3h 9m', '153'] + +--- SERIES STATS TABLES --- + +Most Watched Series Table Rows: + Row 1: ['Landman - s02e05 - The Pirate Dinner', '13', '3h 41m', '3'] + Row 2: ['Skyfall', '12', '20h 16m', '2'] + Row 3: ['Greenland', '11', '2h 16m', '2'] + Row 4: ['Wicked: For Good', '11', '10h 22m', '2'] + Row 5: ['The Beast in Me - s01e05 - Bacchanal', '9', '1h 43m', '2'] + Row 6: ['The Beast in Me - s01e04 - Thanatos', '9', '1h 22m', '2'] + Row 7: ['Homestead', '8', '1h 45m', '1'] + Row 8: ['Down Periscope', '8', '2h 58m', '2'] + Row 9: ['Landman - s02e02 - Sins of the Father', '8', '3h 51m', '4'] + Row 10: ['Black Bird - s01e03 - Hand to Mouth', '8', '23h 0m', '2'] + +Least Watched Series Table Rows: + Row 1: ['Prison Break - s01e02 - Allen', '1', '43m', '1'] + Row 2: ['Quantum of Solace', '1', '1h 51m', '1'] + Row 3: ['The Santa Clause', '1', '4m', '1'] + Row 4: ['Love Island USA: Laid Bare - s05e05 - Unseen Bits', '1', '39m', '1'] + Row 5: ['Ronin', '1', '2m', '1'] + Row 6: ['Love Island USA: Laid Bare - s05e07 - Episode #5.7', '1', '52m', '1'] + Row 7: ['Prison Break - s01e07 - Riots, Drills and the Devil (2)', '1', '40m', '1'] + Row 8: ['Top Guns: The Next Generation - s01e06 - Last Chance', '1', '40m', '1'] + Row 9: ['Top Gear - s18e02 - Episode 2', '1', '18m', '1'] + Row 10: ['Top Gear - s18e01 - Episode 1', '1', '1h 2m', '1'] \ No newline at end of file diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..07a22c9 --- /dev/null +++ b/pyproject.toml @@ -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"] diff --git a/run_with_logging.py b/run_with_logging.py new file mode 100644 index 0000000..d876ff3 --- /dev/null +++ b/run_with_logging.py @@ -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") diff --git a/src/jellycleanarr/__init__.py b/src/jellycleanarr/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/src/jellycleanarr/__main__.py b/src/jellycleanarr/__main__.py new file mode 100644 index 0000000..5eb614f --- /dev/null +++ b/src/jellycleanarr/__main__.py @@ -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()) diff --git a/src/jellycleanarr/api/__init__.py b/src/jellycleanarr/api/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/src/jellycleanarr/api/client.py b/src/jellycleanarr/api/client.py new file mode 100644 index 0000000..0b62394 --- /dev/null +++ b/src/jellycleanarr/api/client.py @@ -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() diff --git a/src/jellycleanarr/config.py b/src/jellycleanarr/config.py new file mode 100644 index 0000000..c01572a --- /dev/null +++ b/src/jellycleanarr/config.py @@ -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() diff --git a/src/jellycleanarr/main.py b/src/jellycleanarr/main.py new file mode 100644 index 0000000..6af0e8c --- /dev/null +++ b/src/jellycleanarr/main.py @@ -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()) diff --git a/src/jellycleanarr/models/__init__.py b/src/jellycleanarr/models/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/src/jellycleanarr/models/media.py b/src/jellycleanarr/models/media.py new file mode 100644 index 0000000..f24d239 --- /dev/null +++ b/src/jellycleanarr/models/media.py @@ -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 diff --git a/src/jellycleanarr/models/playback.py b/src/jellycleanarr/models/playback.py new file mode 100644 index 0000000..a3551c7 --- /dev/null +++ b/src/jellycleanarr/models/playback.py @@ -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 diff --git a/src/jellycleanarr/models/user.py b/src/jellycleanarr/models/user.py new file mode 100644 index 0000000..269625f --- /dev/null +++ b/src/jellycleanarr/models/user.py @@ -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 diff --git a/src/jellycleanarr/services/__init__.py b/src/jellycleanarr/services/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/src/jellycleanarr/services/stats_service.py b/src/jellycleanarr/services/stats_service.py new file mode 100644 index 0000000..1c8fbb4 --- /dev/null +++ b/src/jellycleanarr/services/stats_service.py @@ -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 [], [] diff --git a/src/jellycleanarr/ui/__init__.py b/src/jellycleanarr/ui/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/src/jellycleanarr/ui/app.py b/src/jellycleanarr/ui/app.py new file mode 100644 index 0000000..5551e30 --- /dev/null +++ b/src/jellycleanarr/ui/app.py @@ -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() diff --git a/src/jellycleanarr/ui/screens/__init__.py b/src/jellycleanarr/ui/screens/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/src/jellycleanarr/ui/screens/main_screen.py b/src/jellycleanarr/ui/screens/main_screen.py new file mode 100644 index 0000000..694438b --- /dev/null +++ b/src/jellycleanarr/ui/screens/main_screen.py @@ -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() diff --git a/src/jellycleanarr/ui/styles/main.tcss b/src/jellycleanarr/ui/styles/main.tcss new file mode 100644 index 0000000..7f3ee9a --- /dev/null +++ b/src/jellycleanarr/ui/styles/main.tcss @@ -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; diff --git a/src/jellycleanarr/ui/widgets/__init__.py b/src/jellycleanarr/ui/widgets/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/src/jellycleanarr/ui/widgets/library_stats.py b/src/jellycleanarr/ui/widgets/library_stats.py new file mode 100644 index 0000000..54c4e45 --- /dev/null +++ b/src/jellycleanarr/ui/widgets/library_stats.py @@ -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") diff --git a/src/jellycleanarr/ui/widgets/series_stats.py b/src/jellycleanarr/ui/widgets/series_stats.py new file mode 100644 index 0000000..7e8d2b7 --- /dev/null +++ b/src/jellycleanarr/ui/widgets/series_stats.py @@ -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") diff --git a/src/jellycleanarr/ui/widgets/stats_table.py b/src/jellycleanarr/ui/widgets/stats_table.py new file mode 100644 index 0000000..d950725 --- /dev/null +++ b/src/jellycleanarr/ui/widgets/stats_table.py @@ -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") diff --git a/src/jellycleanarr/ui/widgets/user_stats.py b/src/jellycleanarr/ui/widgets/user_stats.py new file mode 100644 index 0000000..9d0df7d --- /dev/null +++ b/src/jellycleanarr/ui/widgets/user_stats.py @@ -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") diff --git a/src/jellycleanarr/utils/__init__.py b/src/jellycleanarr/utils/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/src/jellycleanarr/utils/exceptions.py b/src/jellycleanarr/utils/exceptions.py new file mode 100644 index 0000000..13ab367 --- /dev/null +++ b/src/jellycleanarr/utils/exceptions.py @@ -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 diff --git a/src/jellycleanarr/utils/formatters.py b/src/jellycleanarr/utils/formatters.py new file mode 100644 index 0000000..d40f032 --- /dev/null +++ b/src/jellycleanarr/utils/formatters.py @@ -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:,}" diff --git a/styles.css b/styles.css new file mode 100644 index 0000000..163579a --- /dev/null +++ b/styles.css @@ -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; + } +} diff --git a/test_data_flow.py b/test_data_flow.py new file mode 100644 index 0000000..2844c73 --- /dev/null +++ b/test_data_flow.py @@ -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()) diff --git a/test_tui_minimal.py b/test_tui_minimal.py new file mode 100644 index 0000000..eb65240 --- /dev/null +++ b/test_tui_minimal.py @@ -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() diff --git a/test_widget_direct.py b/test_widget_direct.py new file mode 100644 index 0000000..0c6be9d --- /dev/null +++ b/test_widget_direct.py @@ -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()) diff --git a/tests/__init__.py b/tests/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/test_api/__init__.py b/tests/test_api/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/test_models/__init__.py b/tests/test_models/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/test_services/__init__.py b/tests/test_services/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/uv.lock b/uv.lock new file mode 100644 index 0000000..7518fc9 --- /dev/null +++ b/uv.lock @@ -0,0 +1,3 @@ +version = 1 +revision = 3 +requires-python = ">=3.12"