Skip to content

Commit 16a6678

Browse files
committed
Merge pull request #212 from Aerya/fix-arvio-episodes
fix(arvio): improve episode identification, profile resolution, and completion handling
2 parents 3fee56c + 8d1a574 commit 16a6678

4 files changed

Lines changed: 227 additions & 50 deletions

File tree

backend/core/arvio.py

Lines changed: 62 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -181,6 +181,43 @@ async def validate_connection(
181181
raise ArvioAPIError(f"ARVIO profile '{profile_id}' not found in account profiles")
182182
return session, profiles
183183

184+
def _extract_profile_data(raw: Any, profile_id: str) -> list[Any]:
185+
if isinstance(raw, list):
186+
return raw
187+
if not isinstance(raw, dict) or not raw:
188+
return []
189+
190+
pid_str = str(profile_id)
191+
# 1. Exact string match
192+
if pid_str in raw and isinstance(raw[pid_str], list):
193+
return raw[pid_str]
194+
195+
# 2. Integer match
196+
if pid_str.isdigit():
197+
pid_int = int(pid_str)
198+
if pid_int in raw and isinstance(raw[pid_int], list):
199+
return raw[pid_int]
200+
201+
# 3. Case-insensitive / suffix match (e.g. "profile_0", "p0", "0")
202+
for k, v in raw.items():
203+
if isinstance(v, list):
204+
k_str = str(k).lower().strip()
205+
if k_str == pid_str.lower() or k_str.endswith(f"_{pid_str}") or k_str.endswith(f"-{pid_str}"):
206+
return v
207+
208+
# 4. If single key in dictionary, return that list
209+
if len(raw) == 1:
210+
val = list(raw.values())[0]
211+
if isinstance(val, list):
212+
return val
213+
214+
# 5. Still unmatched - fail safe rather than combine every profile's data
215+
# together, which would leak another profile's watch history into this
216+
# one on a multi-profile ARVIO account (cases 1-4 above already cover
217+
# exact/int/suffix matches and the single-profile shortcut).
218+
return []
219+
220+
184221
async def pull_sync_data(
185222
url: str,
186223
refresh_token: str,
@@ -194,21 +231,31 @@ async def pull_sync_data(
194231
await on_refresh(session)
195232
payload = await pull_snapshot(url, session.access_token, api_key=api_key)
196233

197-
pid_str = str(profile_id)
198-
raw_movies = payload.get("localWatchedMoviesByProfile", {})
199-
raw_episodes = payload.get("localWatchedEpisodesByProfile", {})
200-
raw_cw = payload.get("localContinueWatchingByProfile", {})
201-
202-
movies_data = raw_movies.get(pid_str, []) if isinstance(raw_movies, dict) else []
203-
episodes_data = raw_episodes.get(pid_str, []) if isinstance(raw_episodes, dict) else []
204-
cw_data = raw_cw.get(pid_str, []) if isinstance(raw_cw, dict) else []
205-
206-
if not isinstance(movies_data, list):
207-
movies_data = []
208-
if not isinstance(episodes_data, list):
209-
episodes_data = []
210-
if not isinstance(cw_data, list):
211-
cw_data = []
234+
raw_movies = (
235+
payload.get("localWatchedMoviesByProfile")
236+
or payload.get("watchedMoviesByProfile")
237+
or payload.get("watchedMovies")
238+
or payload.get("localWatchedMovies")
239+
or {}
240+
)
241+
raw_episodes = (
242+
payload.get("localWatchedEpisodesByProfile")
243+
or payload.get("watchedEpisodesByProfile")
244+
or payload.get("watchedEpisodes")
245+
or payload.get("localWatchedEpisodes")
246+
or {}
247+
)
248+
raw_cw = (
249+
payload.get("localContinueWatchingByProfile")
250+
or payload.get("continueWatchingByProfile")
251+
or payload.get("continueWatching")
252+
or payload.get("localContinueWatching")
253+
or {}
254+
)
255+
256+
movies_data = _extract_profile_data(raw_movies, profile_id)
257+
episodes_data = _extract_profile_data(raw_episodes, profile_id)
258+
cw_data = _extract_profile_data(raw_cw, profile_id)
212259

213260
return session, {
214261
"watched_movies": movies_data,

backend/routers/sync.py

Lines changed: 100 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -4903,7 +4903,11 @@ async def _apply_arvio_watched_movie(
49034903
except (TypeError, ValueError):
49044904
return False
49054905

4906-
watched_at = _parse_arvio_timestamp(item.get("watchedAt") or item.get("timestamp") or item.get("updatedAtMs"))
4906+
# updatedAt (in addition to updatedAtMs) matters here now too: a completed
4907+
# continue-watching movie routed in via _apply_arvio_playback_progress's
4908+
# high-completion branch may only carry that field, same as the episode
4909+
# version of this fallback chain below.
4910+
watched_at = _parse_arvio_timestamp(item.get("watchedAt") or item.get("timestamp") or item.get("updatedAtMs") or item.get("updatedAt"))
49074911

49084912
result = await db.execute(
49094913
select(Media).where(
@@ -4942,34 +4946,92 @@ async def _apply_arvio_watched_movie(
49424946
return False
49434947

49444948

4949+
def _parse_arvio_episode_info(item: dict[str, Any] | str | int) -> tuple[int, int, int] | None:
4950+
"""Extract (show_tmdb_id, season, episode) from various ARVIO item representations."""
4951+
import re
4952+
if isinstance(item, (int, str)):
4953+
item_str = str(item).strip()
4954+
match = re.search(r"(?:tv:|series:|tmdb:)?(\d+)[:_\-\s]+(?:s|season)?(\d+)[:_\-\s]+(?:e|ep|episode)?(\d+)", item_str, re.IGNORECASE)
4955+
if match:
4956+
try:
4957+
return int(match.group(1)), int(match.group(2)), int(match.group(3))
4958+
except ValueError:
4959+
pass
4960+
try:
4961+
parsed = json.loads(item_str)
4962+
if isinstance(parsed, dict):
4963+
item = parsed
4964+
except Exception:
4965+
return None
4966+
4967+
if isinstance(item, dict):
4968+
for field in ("id", "mediaId", "episodeId", "item_id", "itemId"):
4969+
val = item.get(field)
4970+
if isinstance(val, str):
4971+
match = re.search(r"(?:tv:|series:|tmdb:)?(\d+)[:_\-\s]+(?:s|season)?(\d+)[:_\-\s]+(?:e|ep|episode)?(\d+)", val, re.IGNORECASE)
4972+
if match:
4973+
try:
4974+
return int(match.group(1)), int(match.group(2)), int(match.group(3))
4975+
except ValueError:
4976+
pass
4977+
4978+
show_tmdb_id_raw = (
4979+
item.get("showTmdbId")
4980+
or item.get("show_tmdb_id")
4981+
or item.get("showId")
4982+
or item.get("seriesTmdbId")
4983+
or item.get("series_tmdb_id")
4984+
or item.get("seriesId")
4985+
or item.get("series_id")
4986+
or item.get("tmdbId")
4987+
or item.get("tmdb_id")
4988+
)
4989+
season_raw = (
4990+
item.get("season")
4991+
or item.get("seasonNumber")
4992+
or item.get("season_number")
4993+
or item.get("seasonIndex")
4994+
or item.get("s")
4995+
)
4996+
episode_raw = (
4997+
item.get("episode")
4998+
or item.get("episodeNumber")
4999+
or item.get("episode_number")
5000+
or item.get("episodeIndex")
5001+
or item.get("e")
5002+
)
5003+
5004+
if show_tmdb_id_raw is not None and season_raw is not None and episode_raw is not None:
5005+
try:
5006+
return int(show_tmdb_id_raw), int(season_raw), int(episode_raw)
5007+
except (TypeError, ValueError):
5008+
pass
5009+
5010+
return None
5011+
5012+
49455013
async def _apply_arvio_watched_episode(
49465014
db: AsyncSession,
49475015
user_id: int,
49485016
item: dict[str, Any] | int | str,
49495017
tmdb_api_key: str | None,
49505018
) -> bool:
4951-
if not isinstance(item, dict):
5019+
info = _parse_arvio_episode_info(item)
5020+
if not info:
49525021
return False
49535022

4954-
show_tmdb_id_raw = item.get("showTmdbId") or item.get("show_tmdb_id") or item.get("showId") or item.get("id")
4955-
season_raw = item.get("season") or item.get("seasonNumber")
4956-
episode_raw = item.get("episode") or item.get("episodeNumber")
4957-
4958-
if not show_tmdb_id_raw or season_raw is None or episode_raw is None:
4959-
return False
4960-
try:
4961-
show_tmdb_id = int(show_tmdb_id_raw)
4962-
season = int(season_raw)
4963-
episode = int(episode_raw)
4964-
except (TypeError, ValueError):
4965-
return False
5023+
show_tmdb_id, season, episode = info
49665024

4967-
watched_at = _parse_arvio_timestamp(item.get("watchedAt") or item.get("timestamp") or item.get("updatedAtMs"))
5025+
watched_at = None
5026+
if isinstance(item, dict):
5027+
watched_at = _parse_arvio_timestamp(item.get("watchedAt") or item.get("timestamp") or item.get("updatedAtMs") or item.get("updatedAt"))
49685028

49695029
show_res = await db.execute(select(Show).where(Show.tmdb_id == show_tmdb_id))
49705030
show = show_res.scalars().first()
49715031
if not show:
4972-
show_title = str(item.get("title") or item.get("showTitle") or f"Show {show_tmdb_id}")
5032+
show_title = f"Show {show_tmdb_id}"
5033+
if isinstance(item, dict):
5034+
show_title = str(item.get("title") or item.get("showTitle") or item.get("seriesTitle") or show_title)
49735035
show = Show(tmdb_id=show_tmdb_id, title=show_title)
49745036
db.add(show)
49755037
await db.flush()
@@ -4984,7 +5046,9 @@ async def _apply_arvio_watched_episode(
49845046
)
49855047
media = ep_res.scalars().first()
49865048
if not media:
4987-
ep_title = str(item.get("episodeTitle") or f"S{season:02d}E{episode:02d}")
5049+
ep_title = f"S{season:02d}E{episode:02d}"
5050+
if isinstance(item, dict):
5051+
ep_title = str(item.get("episodeTitle") or item.get("title") or ep_title)
49885052
media = Media(
49895053
show_id=show.id,
49905054
season_number=season,
@@ -5025,6 +5089,11 @@ async def _apply_arvio_playback_progress(
50255089
item: dict[str, Any] | int | str,
50265090
tmdb_api_key: str | None,
50275091
) -> bool:
5092+
if isinstance(item, str):
5093+
try:
5094+
item = json.loads(item)
5095+
except Exception:
5096+
pass
50285097
if not isinstance(item, dict):
50295098
return False
50305099

@@ -5037,7 +5106,16 @@ async def _apply_arvio_playback_progress(
50375106
except (TypeError, ValueError):
50385107
progress_pct = 0.0
50395108

5040-
if progress_pct < 1.0 or progress_pct >= 90.0:
5109+
is_completed = item.get("completed") is True or progress_pct >= 85.0
5110+
5111+
if is_completed:
5112+
ep_info = _parse_arvio_episode_info(item)
5113+
if ep_info:
5114+
return await _apply_arvio_watched_episode(db, user_id, item, tmdb_api_key)
5115+
else:
5116+
return await _apply_arvio_watched_movie(db, user_id, item, tmdb_api_key)
5117+
5118+
if progress_pct < 1.0:
50415119
return False
50425120

50435121
pos_sec = item.get("resumePositionSeconds") or item.get("positionSeconds") or item.get("position")
@@ -5063,19 +5141,15 @@ async def _apply_arvio_playback_progress(
50635141
is_episode = (
50645142
media_type_str in ("TV", "EPISODE", "SERIES")
50655143
or (season_raw is not None and episode_raw is not None)
5144+
or _parse_arvio_episode_info(item) is not None
50665145
)
50675146

50685147
media: Media | None = None
50695148
if is_episode:
5070-
show_tmdb_raw = item.get("showTmdbId") or item.get("show_tmdb_id") or item.get("showId") or item.get("id")
5071-
if not show_tmdb_raw or season_raw is None or episode_raw is None:
5072-
return False
5073-
try:
5074-
show_tmdb_id = int(show_tmdb_raw)
5075-
season = int(season_raw)
5076-
episode = int(episode_raw)
5077-
except (TypeError, ValueError):
5149+
ep_info = _parse_arvio_episode_info(item)
5150+
if not ep_info:
50785151
return False
5152+
show_tmdb_id, season, episode = ep_info
50795153

50805154
show_res = await db.execute(select(Show).where(Show.tmdb_id == show_tmdb_id))
50815155
show = show_res.scalars().first()

backend/tests/test_arvio.py

Lines changed: 54 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -198,6 +198,29 @@ async def test_apply_arvio_watched_movie(self) -> None:
198198
self.assertTrue(added)
199199
self.assertEqual(db.add.call_count, 2) # Media + WatchEvent
200200

201+
async def test_apply_arvio_watched_movie_falls_back_to_updated_at(self) -> None:
202+
# Regression: a completed continue-watching movie item (routed here by
203+
# _apply_arvio_playback_progress's high-completion branch) may only
204+
# carry updatedAt, not watchedAt/timestamp/updatedAtMs - without this
205+
# fallback the event's timestamp silently became "now" instead of the
206+
# real watch time.
207+
db = AsyncMock()
208+
db.add = MagicMock()
209+
db.execute = AsyncMock(side_effect=[
210+
_Result(scalars=[]), # Media search
211+
_Result(scalars=[]), # WatchEvent search
212+
])
213+
214+
added = await _apply_arvio_watched_movie(
215+
db,
216+
user_id=1,
217+
item={"tmdbId": 550, "title": "Fight Club", "updatedAt": "2026-08-10T12:00:00Z"},
218+
tmdb_api_key=None,
219+
)
220+
self.assertTrue(added)
221+
event = next(c.args[0] for c in db.add.call_args_list if isinstance(c.args[0], WatchEvent))
222+
self.assertEqual(event.watched_at, datetime(2026, 8, 10, 12, 0, 0))
223+
201224
async def test_apply_arvio_watched_episode(self) -> None:
202225
db = AsyncMock()
203226
db.add = MagicMock()
@@ -257,6 +280,37 @@ async def test_apply_arvio_watched_movie_int_item(self) -> None:
257280
)
258281
self.assertTrue(added)
259282

283+
def test_extract_profile_data_fallbacks(self) -> None:
284+
self.assertEqual(arvio._extract_profile_data(["item1"], "0"), ["item1"])
285+
self.assertEqual(arvio._extract_profile_data({"0": ["item1"]}, "0"), ["item1"])
286+
self.assertEqual(arvio._extract_profile_data({"profile_0": ["item1"]}, "0"), ["item1"])
287+
self.assertEqual(arvio._extract_profile_data({"uuid-123": ["item1"]}, "0"), ["item1"])
288+
289+
def test_extract_profile_data_fails_safe_on_multi_profile_mismatch(self) -> None:
290+
# A multi-profile dict where profile_id matches none of the keys must
291+
# return [] rather than combine every profile's data together - doing
292+
# so would leak another profile's watch history into this one on a
293+
# multi-profile ARVIO account.
294+
self.assertEqual(arvio._extract_profile_data({"p1": ["a"], "p2": ["b"]}, "0"), [])
295+
296+
async def test_apply_arvio_watched_episode_formats(self) -> None:
297+
db = AsyncMock()
298+
db.add = MagicMock()
299+
db.execute = AsyncMock(side_effect=[
300+
_Result(scalars=[]), # Show search
301+
_Result(scalars=[]), # Media episode search
302+
_Result(scalars=[]), # WatchEvent search
303+
])
304+
305+
# Test string format "94997:3:1"
306+
added = await _apply_arvio_watched_episode(
307+
db,
308+
user_id=1,
309+
item="94997:3:1",
310+
tmdb_api_key=None,
311+
)
312+
self.assertTrue(added)
313+
260314

261315
if __name__ == "__main__":
262316
unittest.main()

frontend/src/pages/connections.astro

Lines changed: 11 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -252,15 +252,17 @@ const sonarrConfigured = !!(settings.sonarr_url && settings.sonarr_token);
252252
))}
253253
</select>
254254
</div>
255-
<div class="flex items-center justify-between gap-4">
256-
<label class="text-sm text-zinc-300">Push interval</label>
257-
<select class="conn-auto-push bg-zinc-800 border border-zinc-700 rounded-lg px-3 py-2 text-sm text-zinc-100 focus:outline-none focus:ring-2 focus:ring-blue-500 transition-all cursor-pointer" data-conn-id={conn.id}>
258-
<option value="" selected={!conn.auto_push_interval}>Disabled</option>
259-
{AUTO_SYNC_INTERVAL_OPTIONS.map(({ value, label }) => (
260-
<option value={String(value)} selected={conn.auto_push_interval === value}>{label}</option>
261-
))}
262-
</select>
263-
</div>
255+
{conn.type !== "arvio" && conn.type !== "nuvio" && conn.type !== "stremio" && (
256+
<div class="flex items-center justify-between gap-4">
257+
<label class="text-sm text-zinc-300">Push interval</label>
258+
<select class="conn-auto-push bg-zinc-800 border border-zinc-700 rounded-lg px-3 py-2 text-sm text-zinc-100 focus:outline-none focus:ring-2 focus:ring-blue-500 transition-all cursor-pointer" data-conn-id={conn.id}>
259+
<option value="" selected={!conn.auto_push_interval}>Disabled</option>
260+
{AUTO_SYNC_INTERVAL_OPTIONS.map(({ value, label }) => (
261+
<option value={String(value)} selected={conn.auto_push_interval === value}>{label}</option>
262+
))}
263+
</select>
264+
</div>
265+
)}
264266
</div>
265267
{conn.type === "plex" && (
266268
<div class="bg-zinc-950 rounded-xl p-4 space-y-3">

0 commit comments

Comments
 (0)