Skip to content

Commit dc94367

Browse files
authored
feat(plex): add Plex as watch data provider alternative to Tautulli (#252)
* refactor(watch): abstract watch data provider behind protocol Introduce WatchDataProvider protocol and factory function to decouple media_cleaner from direct Tautulli dependency, enabling future alternative providers (Plex API, Jellyfin). * feat(plex): add Plex as watch data provider alternative to Tautulli When Tautulli is not configured, Deleterr now falls back to using the Plex API directly for watch history data. This removes the hard dependency on Tautulli for users who prefer a simpler setup. Closes #224 * fix(plex): store last_watched as datetime, fix cache policy, request full history
1 parent ac6d2de commit dc94367

12 files changed

Lines changed: 488 additions & 14 deletions

app/config.py

Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -373,6 +373,50 @@ def test_api_connection(self, connection):
373373
return False
374374

375375
def validate_watch_provider(self):
376+
tautulli_config = self.settings.get("tautulli")
377+
if tautulli_config:
378+
return self._validate_tautulli_watch_provider()
379+
380+
return self._validate_plex_watch_provider()
381+
382+
def _validate_plex_watch_provider(self):
383+
plex_config = self.settings.get("plex")
384+
if not plex_config:
385+
logger.error(
386+
"No watch data provider configured. "
387+
"Add 'tautulli' section with 'url' and 'api_key', or configure "
388+
"'plex' with 'url' and 'token' to use Plex directly."
389+
)
390+
return False
391+
392+
try:
393+
from app.modules.plex_watch_provider import PlexWatchProvider
394+
395+
ssl_verify = self.settings.get("ssl_verify", False)
396+
plex_provider = PlexWatchProvider(
397+
plex_config["url"], plex_config["token"], ssl_verify=ssl_verify
398+
)
399+
plex_provider.test_connection()
400+
logger.info("Using Plex as watch data provider (no Tautulli configured)")
401+
return True
402+
except Exception as err:
403+
url = plex_config.get("url", "unknown")
404+
error_msg = str(err).lower()
405+
if "401" in error_msg or "unauthorized" in error_msg:
406+
logger.error(
407+
f"Plex authentication failed at {url}: {err}. "
408+
"Verify your Plex token is correct."
409+
)
410+
elif "timeout" in error_msg or "connection" in error_msg:
411+
logger.error(
412+
f"Cannot reach Plex at {url}: {err}. "
413+
"Check the URL and ensure Plex is running."
414+
)
415+
else:
416+
logger.error(f"Plex connection failed at {url}: {err}")
417+
return False
418+
419+
def _validate_tautulli_watch_provider(self):
376420
try:
377421
tautulli_config = self.settings.get("tautulli")
378422
if not tautulli_config:

app/modules/plex_watch_provider.py

Lines changed: 147 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,147 @@
1+
# encoding: utf-8
2+
3+
from datetime import datetime
4+
from typing import Dict, Optional
5+
6+
from plexapi.server import PlexServer
7+
8+
from app import logger
9+
10+
11+
class PlexWatchProvider:
12+
"""Watch data provider using Plex API directly (no Tautulli needed)."""
13+
14+
def __init__(self, plex_url, plex_token, ssl_verify=False):
15+
session = None
16+
if not ssl_verify:
17+
import requests
18+
19+
session = requests.Session()
20+
session.verify = False
21+
self.plex = PlexServer(plex_url, plex_token, session=session)
22+
self._account_id_cache = {}
23+
24+
def test_connection(self):
25+
self.plex.library.sections()
26+
27+
def refresh_library(self, section_id):
28+
self.plex.library.sectionByID(int(section_id)).refresh()
29+
30+
def get_activity(self, section) -> Dict[str, Dict]:
31+
"""Get watch activity for a library section.
32+
33+
Fetches history from Plex and transforms into the same format as
34+
Tautulli: a dict keyed by GUID and rating key, with values containing
35+
last_watched, title, and year.
36+
"""
37+
history = self.plex.history(librarySectionID=int(section), maxresults=100000)
38+
39+
if not history:
40+
return {}
41+
42+
last_activity = {}
43+
for item in history:
44+
viewed_at = item.viewedAt
45+
if not viewed_at:
46+
continue
47+
if isinstance(viewed_at, datetime):
48+
last_watched = viewed_at
49+
else:
50+
last_watched = datetime.fromtimestamp(float(viewed_at))
51+
52+
is_episode = item.type == "episode"
53+
title = item.grandparentTitle if is_episode else item.title
54+
year = getattr(item, "year", None)
55+
if is_episode:
56+
year = getattr(item, "grandparentYear", year)
57+
58+
activity_entry = {
59+
"last_watched": last_watched,
60+
"title": title,
61+
"year": year,
62+
}
63+
64+
# Key by GUID
65+
guid = getattr(item, "guid", None)
66+
if guid:
67+
self._update_if_newer(last_activity, guid, activity_entry)
68+
69+
# Key by rating key (grandparentRatingKey for episodes, ratingKey for movies)
70+
if is_episode:
71+
grandparent_key = getattr(item, "grandparentRatingKey", None)
72+
if grandparent_key:
73+
self._update_if_newer(
74+
last_activity, str(grandparent_key), activity_entry
75+
)
76+
else:
77+
rating_key = getattr(item, "ratingKey", None)
78+
if rating_key:
79+
self._update_if_newer(
80+
last_activity, str(rating_key), activity_entry
81+
)
82+
83+
logger.debug("Processed %d unique items from Plex history", len(last_activity))
84+
return last_activity
85+
86+
def has_user_watched(self, section, rating_key, grandparent_rating_key, user):
87+
"""Check if a specific user has watched a media item.
88+
89+
Args:
90+
section: Plex library section ID
91+
rating_key: Rating key for movies
92+
grandparent_rating_key: Grandparent rating key for TV shows
93+
user: Plex username to check
94+
95+
Returns:
96+
True if the user has watched the item, False otherwise
97+
"""
98+
if not user:
99+
return False
100+
101+
key = grandparent_rating_key or rating_key
102+
if not key:
103+
return False
104+
105+
account_id = self._get_account_id(user)
106+
if account_id is None:
107+
logger.warning(f"Could not find Plex account for user '{user}'")
108+
return False
109+
110+
try:
111+
results = self.plex.history(
112+
librarySectionID=int(section),
113+
ratingKey=int(key),
114+
accountID=account_id,
115+
maxresults=1,
116+
)
117+
return len(results) > 0
118+
except Exception as e:
119+
logger.warning(
120+
f"Failed to check Plex watch history for user '{user}' "
121+
f"(key: {key}): {e}"
122+
)
123+
return False
124+
125+
def _get_account_id(self, username) -> Optional[int]:
126+
"""Resolve a Plex username to a SystemAccount ID, with caching."""
127+
if username in self._account_id_cache:
128+
return self._account_id_cache[username]
129+
130+
try:
131+
for account in self.plex.systemAccounts():
132+
if account.name == username:
133+
self._account_id_cache[username] = account.id
134+
return account.id
135+
# Lookup succeeded but user was not found - cache the negative result
136+
self._account_id_cache[username] = None
137+
return None
138+
except Exception as e:
139+
# Do not cache on failure so subsequent calls can retry the API
140+
logger.warning(f"Failed to fetch Plex system accounts: {e}")
141+
return None
142+
143+
@staticmethod
144+
def _update_if_newer(activity_dict, key, entry):
145+
"""Only update the entry if it's newer than the existing one."""
146+
if key not in activity_dict or entry["last_watched"] > activity_dict[key]["last_watched"]:
147+
activity_dict[key] = entry

app/modules/watch_provider.py

Lines changed: 27 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
# encoding: utf-8
22

3-
from typing import Dict, Protocol, runtime_checkable
3+
from typing import Dict, Optional, Protocol, runtime_checkable
44

55
from app import logger
66
from app.modules.tautulli import Tautulli
@@ -22,12 +22,22 @@ def refresh_library(self, section_id: str) -> None:
2222
"""Refresh library metadata."""
2323
...
2424

25+
def has_user_watched(
26+
self,
27+
section: str,
28+
rating_key: Optional[str],
29+
grandparent_rating_key: Optional[str],
30+
user: str,
31+
) -> bool:
32+
"""Check if a specific user has watched a media item."""
33+
...
34+
2535

2636
def create_watch_provider(config, ssl_verify=False) -> WatchDataProvider:
2737
"""Create a watch data provider from config.
2838
29-
Currently supports Tautulli only. Future providers (Plex, Jellyfin)
30-
will be added here.
39+
If 'tautulli' is configured, uses Tautulli. Otherwise falls back to
40+
Plex's built-in watch history API.
3141
3242
Args:
3343
config: Application config object with settings dict
@@ -37,7 +47,7 @@ def create_watch_provider(config, ssl_verify=False) -> WatchDataProvider:
3747
A WatchDataProvider instance
3848
3949
Raises:
40-
KeyError: If no watch provider is configured
50+
KeyError: If neither tautulli nor plex is configured
4151
"""
4252
tautulli_config = config.settings.get("tautulli")
4353
if tautulli_config:
@@ -48,7 +58,19 @@ def create_watch_provider(config, ssl_verify=False) -> WatchDataProvider:
4858
ssl_verify=ssl_verify,
4959
)
5060

61+
plex_config = config.settings.get("plex")
62+
if plex_config:
63+
from app.modules.plex_watch_provider import PlexWatchProvider
64+
65+
logger.debug("Using Plex as watch data provider (no Tautulli configured)")
66+
return PlexWatchProvider(
67+
plex_config["url"],
68+
plex_config["token"],
69+
ssl_verify=ssl_verify,
70+
)
71+
5172
raise KeyError(
5273
"No watch data provider configured. "
53-
"Add 'tautulli' section with 'url' and 'api_key' to your config."
74+
"Add 'tautulli' section with 'url' and 'api_key', or configure "
75+
"'plex' with 'url' and 'token' to use Plex directly."
5476
)

app/schema.py

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -891,9 +891,9 @@ class DeleterrConfig(BaseModel):
891891
...,
892892
description="Plex server connection settings",
893893
)
894-
tautulli: TautulliConfig = Field(
895-
...,
896-
description="Tautulli connection settings",
894+
tautulli: Optional[TautulliConfig] = Field(
895+
default=None,
896+
description="Tautulli connection settings. If not configured, Plex API is used for watch history instead",
897897
)
898898
radarr: list[RadarrInstance] = Field(
899899
default_factory=list,
@@ -944,3 +944,4 @@ def check_instances_exist(self):
944944
if not self.radarr and not self.sonarr:
945945
raise ValueError("At least one Radarr or Sonarr instance must be configured")
946946
return self
947+

config/settings.yaml.example

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -23,7 +23,8 @@ sonarr:
2323
url: "http://localhost:8990"
2424
api_key: "YOUR_SONARR_API_KEY2"
2525

26-
# Tautulli connection details
26+
# Tautulli connection details (optional)
27+
# If configured, Tautulli is used for watch history. Otherwise, Plex API is used directly.
2728
tautulli:
2829
url: "http://localhost:8181" # Replace with your Tautulli server URL
2930
api_key: "YOUR_TAUTULLI_API_KEY" # Replace with your Tautulli API key

docs/getting-started.md

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -9,9 +9,9 @@ This guide walks you through deploying Deleterr and running your first cleanup.
99
Before starting, ensure you have:
1010

1111
- [ ] Plex Media Server with a valid token ([how to get token](https://support.plex.tv/articles/204059436-finding-an-authentication-token-x-plex-token/))
12-
- [ ] Tautulli installed and configured with API key
1312
- [ ] Radarr and/or Sonarr with API keys
1413
- [ ] Docker installed on your system
14+
- [ ] (Optional) Tautulli installed and configured with API key - if not configured, Plex API is used for watch history
1515

1616
---
1717

@@ -99,6 +99,7 @@ plex:
9999
url: "http://plex:32400"
100100
token: "YOUR_PLEX_TOKEN"
101101

102+
# Tautulli is optional - if not configured, Plex API is used for watch history
102103
tautulli:
103104
url: "http://tautulli:8181"
104105
api_key: "YOUR_TAUTULLI_API_KEY"
@@ -219,7 +220,8 @@ docker compose run --rm deleterr
219220

220221
Check the logs for:
221222

222-
- Successful connections to Plex, Tautulli, Radarr/Sonarr
223+
- Successful connections to Plex, Radarr/Sonarr, and Tautulli (if configured)
224+
- Watch data provider selection (Tautulli or Plex)
223225
- Media items identified for deletion
224226
- Any errors or warnings
225227

docs/troubleshooting.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -87,7 +87,7 @@ Common issues and solutions for Deleterr.
8787
added_at_threshold: 180 # Added 180+ days ago
8888
```
8989
90-
2. **Verify watch history**: Ensure Tautulli has watch data
90+
2. **Verify watch history**: Ensure Tautulli (or Plex, if no Tautulli configured) has watch data
9191
9292
3. **Check exclusions**: Your exclusion rules might be too broad
9393

scripts/generate_docs.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -263,7 +263,7 @@ def main():
263263
264264
## Tautulli
265265
266-
**Required.** Connection details for Tautulli (watch history tracking).
266+
Optional. Connection details for Tautulli (watch history tracking). If not configured, Deleterr uses the Plex API directly for watch history.
267267
268268
{tautulli_table}
269269
Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,30 @@
1+
# Config without Tautulli - uses Plex API directly for watch history
2+
plex:
3+
url: "http://localhost:32400"
4+
token: "YOUR_PLEX_TOKEN"
5+
6+
radarr:
7+
- name: "Radarr"
8+
url: "http://localhost:7878"
9+
api_key: "YOUR_RADARR_API_KEY"
10+
11+
sonarr:
12+
- name: "Sonarr"
13+
url: "http://localhost:8989"
14+
api_key: "YOUR_SONARR_API_KEY"
15+
16+
dry_run: true
17+
18+
libraries:
19+
- name: "Movies"
20+
radarr: "Radarr"
21+
action_mode: "delete"
22+
last_watched_threshold: 90
23+
added_at_threshold: 180
24+
max_actions_per_run: 10
25+
- name: "TV Shows"
26+
sonarr: "Sonarr"
27+
action_mode: "delete"
28+
last_watched_threshold: 365
29+
added_at_threshold: 180
30+
max_actions_per_run: 10

0 commit comments

Comments
 (0)