Hermes Agent commited on
Commit
aade177
·
1 Parent(s): 5955a8b

fix: eliminate duplicate movie entries, prevent moving dubbed releases across language folders, and enable dynamic backdrop rotation in spotlight

Browse files
apex/combined-entrypoint.sh CHANGED
@@ -59,6 +59,11 @@ restore_jellyfin_local() {
59
  find /data/jellyfin/media /data/movies /data/tvshows /data /opt/jellyfin-local -iname "*mot*test*servicing*" -exec rm -rf {} + 2>/dev/null || true
60
  find /data/jellyfin/media /data/movies /data/tvshows /data /opt/jellyfin-local -iname "*rose*cane*" -o -iname "*ms.rose*" -o -iname "*ms rose*" -exec rm -rf {} + 2>/dev/null || true
61
  find /data/jellyfin/media/Movies/Telugu /data/movies/Telugu /opt/jellyfin-local -iname "*kattalan*" -exec rm -rf {} + 2>/dev/null || true
 
 
 
 
 
62
 
63
  # Purge old low-res Jellyfin image cache so newly fetched HD/4K artwork is displayed immediately
64
  rm -rf "$LOCAL_ROOT/cache/images"/* /data/jellyfin/cache/images/* 2>/dev/null || true
 
59
  find /data/jellyfin/media /data/movies /data/tvshows /data /opt/jellyfin-local -iname "*mot*test*servicing*" -exec rm -rf {} + 2>/dev/null || true
60
  find /data/jellyfin/media /data/movies /data/tvshows /data /opt/jellyfin-local -iname "*rose*cane*" -o -iname "*ms.rose*" -o -iname "*ms rose*" -exec rm -rf {} + 2>/dev/null || true
61
  find /data/jellyfin/media/Movies/Telugu /data/movies/Telugu /opt/jellyfin-local -iname "*kattalan*" -exec rm -rf {} + 2>/dev/null || true
62
+ # Clean up empty movie directories or orphaned folders left behind after moves
63
+ find /data/jellyfin/media/Movies -mindepth 2 -type d -empty -delete 2>/dev/null || true
64
+ if [ -d "/data/jellyfin/media/Movies/English" ]; then
65
+ find "/data/jellyfin/media/Movies/English" -mindepth 1 -maxdepth 1 -type d -exec bash -c 'for d; do [ -z "$(find "$d" -name "*.strm" 2>/dev/null)" ] && rm -rf "$d"; done' _ {} + 2>/dev/null || true
66
+ fi
67
 
68
  # Purge old low-res Jellyfin image cache so newly fetched HD/4K artwork is displayed immediately
69
  rm -rf "$LOCAL_ROOT/cache/images"/* /data/jellyfin/cache/images/* 2>/dev/null || true
apex/repair_jellyfin_libraries.py CHANGED
@@ -119,6 +119,44 @@ def repair_database(db_path: str) -> bool:
119
  if purged > 0:
120
  print(f"[REPAIR] Purged {purged} blacklisted / stale item rows from {db_path}.")
121
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
122
  conn.execute("COMMIT")
123
  conn.close()
124
  if repaired:
@@ -131,6 +169,24 @@ def repair_database(db_path: str) -> bool:
131
  return False
132
 
133
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
134
  def purge_plugin_databases():
135
  """Scan Jellyfin local and persistent data dirs for all plugin SQLite databases
136
  (e.g., IntroSkipper.db, SkipMe.db) and purge any queued or metadata rows referencing
@@ -178,6 +234,7 @@ def main():
178
  for db in TARGET_DATABASES:
179
  repair_database(db)
180
  purge_plugin_databases()
 
181
 
182
 
183
  if __name__ == "__main__":
 
119
  if purged > 0:
120
  print(f"[REPAIR] Purged {purged} blacklisted / stale item rows from {db_path}.")
121
 
122
+ # Clean up orphaned rows whose media files do not exist on disk (prevents duplicate ghost items)
123
+ cur.execute(
124
+ "SELECT Id, Path, Name FROM BaseItems "
125
+ "WHERE (Path LIKE '/data/jellyfin/media/%' OR Path LIKE '/opt/jellyfin-local/%' OR Path LIKE '/data/movies/%') "
126
+ "AND (type LIKE '%Movie%' OR type LIKE '%Episode%')"
127
+ )
128
+ orphaned = []
129
+ for row_id, item_path, item_name in cur.fetchall():
130
+ if item_path and not os.path.exists(item_path):
131
+ orphaned.append(row_id)
132
+ if orphaned:
133
+ cur.executemany("DELETE FROM BaseItems WHERE Id = ?", [(oid,) for oid in orphaned])
134
+ print(f"[REPAIR] Removed {len(orphaned)} orphaned item row(s) whose files no longer exist on disk.")
135
+
136
+ # Clean up duplicate Movie titles if any exist in BaseItems (e.g. duplicate Ne Zha 2)
137
+ cur.execute(
138
+ "SELECT Id, Path, Name FROM BaseItems WHERE type LIKE '%Movie%' AND Path IS NOT NULL"
139
+ )
140
+ seen_names = {}
141
+ dup_to_delete = []
142
+ for row_id, item_path, item_name in cur.fetchall():
143
+ if not item_name:
144
+ continue
145
+ clean_name = item_name.strip().lower()
146
+ exists = os.path.exists(item_path) if item_path else False
147
+ if clean_name in seen_names:
148
+ prev_id, prev_path, prev_exists = seen_names[clean_name]
149
+ if not exists:
150
+ dup_to_delete.append(row_id)
151
+ elif not prev_exists:
152
+ dup_to_delete.append(prev_id)
153
+ seen_names[clean_name] = (row_id, item_path, exists)
154
+ else:
155
+ seen_names[clean_name] = (row_id, item_path, exists)
156
+ if dup_to_delete:
157
+ cur.executemany("DELETE FROM BaseItems WHERE Id = ?", [(did,) for did in dup_to_delete])
158
+ print(f"[REPAIR] Removed {len(dup_to_delete)} duplicate movie row(s) from {db_path}.")
159
+
160
  conn.execute("COMMIT")
161
  conn.close()
162
  if repaired:
 
169
  return False
170
 
171
 
172
+ def cleanup_empty_media_folders(roots=None):
173
+ """Remove abandoned movie/show directories that contain no playable media files."""
174
+ search_roots = roots or ["/data/jellyfin/media/Movies", "/data/jellyfin/media/TV Shows", "/opt/jellyfin-local/media"]
175
+ for root_dir in search_roots:
176
+ if not os.path.exists(root_dir):
177
+ continue
178
+ for dirpath, dirnames, filenames in os.walk(root_dir, topdown=False):
179
+ if dirpath == root_dir:
180
+ continue
181
+ media_files = [f for f in filenames if f.endswith((".strm", ".mp4", ".mkv", ".avi", ".ts"))]
182
+ if not media_files and not dirnames:
183
+ try:
184
+ import shutil
185
+ shutil.rmtree(dirpath, ignore_errors=True)
186
+ except Exception:
187
+ pass
188
+
189
+
190
  def purge_plugin_databases():
191
  """Scan Jellyfin local and persistent data dirs for all plugin SQLite databases
192
  (e.g., IntroSkipper.db, SkipMe.db) and purge any queued or metadata rows referencing
 
234
  for db in TARGET_DATABASES:
235
  repair_database(db)
236
  purge_plugin_databases()
237
+ cleanup_empty_media_folders()
238
 
239
 
240
  if __name__ == "__main__":
apex/tests/test_repair_jellyfin_libraries.py ADDED
@@ -0,0 +1,82 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import sqlite3
2
+ import os
3
+ import shutil
4
+ import tempfile
5
+ import pytest
6
+
7
+ from repair_jellyfin_libraries import repair_database, cleanup_empty_media_folders
8
+
9
+
10
+ def test_repair_database_removes_orphans_and_duplicates(tmp_path):
11
+ db_file = tmp_path / "jellyfin.db"
12
+ conn = sqlite3.connect(str(db_file))
13
+ cur = conn.cursor()
14
+ cur.execute("""
15
+ CREATE TABLE BaseItems (
16
+ Id TEXT PRIMARY KEY,
17
+ Path TEXT,
18
+ Name TEXT,
19
+ type TEXT,
20
+ Data TEXT,
21
+ DateModified TEXT
22
+ )
23
+ """)
24
+
25
+ # Valid media file
26
+ media_dir = tmp_path / "media" / "Movies" / "Ne Zha 2 (2025)"
27
+ media_dir.mkdir(parents=True)
28
+ valid_file = media_dir / "Ne Zha 2 (2025).strm"
29
+ valid_file.write_text("http://stream")
30
+
31
+ # Stale/orphaned media path (does not exist on disk)
32
+ stale_path = str(tmp_path / "media" / "Movies" / "English" / "Ne Zha 2 (2025)" / "Ne Zha 2 (2025).strm")
33
+
34
+ cur.execute(
35
+ "INSERT INTO BaseItems VALUES (?, ?, ?, ?, ?, ?)",
36
+ ("item-valid", str(valid_file), "Ne Zha 2", "MediaBrowser.Controller.Entities.Movies.Movie", "{}", "2026-09-27T00:00:00Z")
37
+ )
38
+ cur.execute(
39
+ "INSERT INTO BaseItems VALUES (?, ?, ?, ?, ?, ?)",
40
+ ("item-stale", stale_path, "Ne Zha 2", "MediaBrowser.Controller.Entities.Movies.Movie", "{}", "2026-09-27T00:00:00Z")
41
+ )
42
+ cur.execute(
43
+ "INSERT INTO BaseItems VALUES (?, ?, ?, ?, ?, ?)",
44
+ ("item-blacklisted", "/data/jellyfin/media/Movies/Rose and Cane.strm", "Ms.Rose & Mr.Cane", "MediaBrowser.Controller.Entities.Movies.Movie", "{}", "2026-09-27T00:00:00Z")
45
+ )
46
+ conn.commit()
47
+ conn.close()
48
+
49
+ # Run repair
50
+ repair_database(str(db_file))
51
+
52
+ conn = sqlite3.connect(str(db_file))
53
+ cur = conn.cursor()
54
+ cur.execute("SELECT Id, Name, Path FROM BaseItems")
55
+ remaining = cur.fetchall()
56
+ conn.close()
57
+
58
+ # Only the valid file remains; stale orphan and blacklisted must be purged
59
+ assert len(remaining) == 1
60
+ assert remaining[0][0] == "item-valid"
61
+ assert remaining[0][1] == "Ne Zha 2"
62
+
63
+
64
+ def test_cleanup_empty_media_folders(tmp_path):
65
+ movies_root = tmp_path / "Movies"
66
+ movies_root.mkdir()
67
+
68
+ # Empty movie dir
69
+ empty_movie = movies_root / "English" / "Ne Zha 2 (2025)"
70
+ empty_movie.mkdir(parents=True)
71
+ (empty_movie / "movie.nfo").write_text("<movie/>") # sidecar only, no video
72
+
73
+ # Valid movie dir with .strm
74
+ valid_movie = movies_root / "Chinese" / "Ne Zha 2 (2025)"
75
+ valid_movie.mkdir(parents=True)
76
+ (valid_movie / "Ne Zha 2 (2025).strm").write_text("http://stream")
77
+
78
+ # Run cleanup
79
+ cleanup_empty_media_folders(roots=[str(movies_root)])
80
+
81
+ assert not empty_movie.exists()
82
+ assert valid_movie.exists()
apex/tg_streamer.py CHANGED
@@ -1754,32 +1754,30 @@ async def _enrich_media_metadata(entry):
1754
  # Filename tags are only a hint. TMDB's original_language is the
1755
  # authoritative fallback for files such as a Malayalam movie whose
1756
  # filename does not contain "Malayalam", "mal", or a language code.
 
 
 
 
1757
  tmdb_language = language_name_from_code(detail.get("original_language"))
1758
- if tmdb_language and tmdb_language != entry.get("language"):
 
1759
  old_strm_path = entry.get("strm_path")
1760
- entry["language"] = tmdb_language
1761
- new_target_dir = strm_dir(MOVIES_DIR, SHOWS_DIR, entry)
1762
- new_show_root = (
1763
- tv_show_root(SHOWS_DIR, tmdb_language, entry.get("show_name"), entry.get("year"))
1764
- if entry.get("is_tv") and entry.get("show_name")
1765
- else None
1766
- )
1767
- new_strm_path = os.path.join(
1768
- new_target_dir, f"{entry.get('strm_base', strm_base(entry))}.strm"
1769
- )
1770
- if old_strm_path and os.path.exists(old_strm_path) and old_strm_path != new_strm_path:
1771
- os.makedirs(new_target_dir, exist_ok=True)
1772
- os.replace(old_strm_path, new_strm_path)
1773
- logger.info(
1774
- f"[ORGANIZE] Moved '{entry.get('title')}' to {tmdb_language} folder"
1775
  )
1776
- entry["strm_path"] = new_strm_path
1777
- if old_strm_path and old_strm_path != new_strm_path:
1778
- entry["_prev_strm_path"] = old_strm_path
1779
- entry["target_dir"] = new_target_dir
1780
- entry["show_root"] = new_show_root
1781
- target_dir = new_target_dir
1782
- show_root = new_show_root
 
1783
 
1784
  os.makedirs(target_dir, exist_ok=True)
1785
  connector = aiohttp.TCPConnector(family=socket.AF_INET)
@@ -1865,6 +1863,26 @@ async def _enrich_media_metadata(entry):
1865
  if not _image_is_hd(poster_path, _POSTER_MIN_WIDTH):
1866
  _mark_art_best_effort(show_root)
1867
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1868
  season_detail = None
1869
  season_number = int(entry.get("season") or 1)
1870
  season_detail = next(
@@ -1931,6 +1949,26 @@ async def _enrich_media_metadata(entry):
1931
  if not _image_is_hd(movie_poster, _POSTER_MIN_WIDTH):
1932
  _mark_art_best_effort(target_dir)
1933
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1934
  prev = FILE_ID_CACHE.get(key)
1935
  if isinstance(prev, dict):
1936
  prev["tmdb_id"] = tmdb_id
@@ -4280,6 +4318,18 @@ async def _reconcile_and_upgrade_library():
4280
  logger.info(f"[PURGE] Removed misplaced Kattalan Telugu directory: {dn}")
4281
  except OSError:
4282
  pass
 
 
 
 
 
 
 
 
 
 
 
 
4283
 
4284
  # Step 2: Remove blacklisted items from FILE_ID_CACHE
4285
  keys_to_purge = []
 
1754
  # Filename tags are only a hint. TMDB's original_language is the
1755
  # authoritative fallback for files such as a Malayalam movie whose
1756
  # filename does not contain "Malayalam", "mal", or a language code.
1757
+ # Record TMDB original_language as metadata, but DO NOT move an existing
1758
+ # .strm file across language folders on disk. The upload may be dubbed (e.g.
1759
+ # an English dubbed Chinese movie like Ne Zha 2), and moving files on disk
1760
+ # leaves orphaned items in jellyfin.db, producing duplicate movie cards.
1761
  tmdb_language = language_name_from_code(detail.get("original_language"))
1762
+ if tmdb_language:
1763
+ entry["tmdb_original_language"] = tmdb_language
1764
  old_strm_path = entry.get("strm_path")
1765
+ if tmdb_language != entry.get("language") and (not old_strm_path or not os.path.exists(old_strm_path)):
1766
+ entry["language"] = tmdb_language
1767
+ new_target_dir = strm_dir(MOVIES_DIR, SHOWS_DIR, entry)
1768
+ new_show_root = (
1769
+ tv_show_root(SHOWS_DIR, tmdb_language, entry.get("show_name"), entry.get("year"))
1770
+ if entry.get("is_tv") and entry.get("show_name")
1771
+ else None
 
 
 
 
 
 
 
 
1772
  )
1773
+ new_strm_path = os.path.join(
1774
+ new_target_dir, f"{entry.get('strm_base', strm_base(entry))}.strm"
1775
+ )
1776
+ entry["strm_path"] = new_strm_path
1777
+ entry["target_dir"] = new_target_dir
1778
+ entry["show_root"] = new_show_root
1779
+ target_dir = new_target_dir
1780
+ show_root = new_show_root
1781
 
1782
  os.makedirs(target_dir, exist_ok=True)
1783
  connector = aiohttp.TCPConnector(family=socket.AF_INET)
 
1863
  if not _image_is_hd(poster_path, _POSTER_MIN_WIDTH):
1864
  _mark_art_best_effort(show_root)
1865
 
1866
+ # Download extra high-res backdrops so Jellyfin indexes multiple BackdropImageTags for dynamic rotation
1867
+ images_data = detail.get("images") or {}
1868
+ backdrops_list = list(images_data.get("backdrops") or [])
1869
+ if len(backdrops_list) > 1 and show_root:
1870
+ primary_file = os.path.basename(backdrop_url) if backdrop_url else ""
1871
+ extra_candidates = [
1872
+ b for b in backdrops_list
1873
+ if b.get("file_path") and os.path.basename(b.get("file_path")) != primary_file
1874
+ ]
1875
+ extra_candidates.sort(
1876
+ key=lambda b: (b.get("width", 0) >= 1280, b.get("width", 0), b.get("vote_average", 0)),
1877
+ reverse=True,
1878
+ )
1879
+ for b_idx, b_item in enumerate(extra_candidates[:3], start=1):
1880
+ b_fp = b_item.get("file_path")
1881
+ b_dst = os.path.join(show_root, f"backdrop{b_idx}.jpg")
1882
+ if not os.path.exists(b_dst) or os.path.getsize(b_dst) < 100000:
1883
+ b_url = f"https://image.tmdb.org/t/p/original{b_fp}"
1884
+ await _download_image(session, b_url, b_dst)
1885
+
1886
  season_detail = None
1887
  season_number = int(entry.get("season") or 1)
1888
  season_detail = next(
 
1949
  if not _image_is_hd(movie_poster, _POSTER_MIN_WIDTH):
1950
  _mark_art_best_effort(target_dir)
1951
 
1952
+ # Download extra high-res backdrops so Jellyfin indexes multiple BackdropImageTags for dynamic rotation
1953
+ images_data = detail.get("images") or {}
1954
+ backdrops_list = list(images_data.get("backdrops") or [])
1955
+ if len(backdrops_list) > 1 and target_dir:
1956
+ primary_file = os.path.basename(backdrop_url) if backdrop_url else ""
1957
+ extra_candidates = [
1958
+ b for b in backdrops_list
1959
+ if b.get("file_path") and os.path.basename(b.get("file_path")) != primary_file
1960
+ ]
1961
+ extra_candidates.sort(
1962
+ key=lambda b: (b.get("width", 0) >= 1280, b.get("width", 0), b.get("vote_average", 0)),
1963
+ reverse=True,
1964
+ )
1965
+ for b_idx, b_item in enumerate(extra_candidates[:3], start=1):
1966
+ b_fp = b_item.get("file_path")
1967
+ b_dst = os.path.join(target_dir, f"backdrop{b_idx}.jpg")
1968
+ if not os.path.exists(b_dst) or os.path.getsize(b_dst) < 100000:
1969
+ b_url = f"https://image.tmdb.org/t/p/original{b_fp}"
1970
+ await _download_image(session, b_url, b_dst)
1971
+
1972
  prev = FILE_ID_CACHE.get(key)
1973
  if isinstance(prev, dict):
1974
  prev["tmdb_id"] = tmdb_id
 
4318
  logger.info(f"[PURGE] Removed misplaced Kattalan Telugu directory: {dn}")
4319
  except OSError:
4320
  pass
4321
+ # Remove abandoned movie directories that have no media (.strm) files left
4322
+ full_dn = os.path.join(dirpath, dn)
4323
+ if root == MOVIES_DIR and os.path.isdir(full_dn):
4324
+ try:
4325
+ subdirs = [sd for sd in os.listdir(full_dn) if os.path.isdir(os.path.join(full_dn, sd))]
4326
+ if not subdirs:
4327
+ has_strm = any(f.endswith((".strm", ".mp4", ".mkv")) for f in os.listdir(full_dn))
4328
+ if not has_strm:
4329
+ shutil.rmtree(full_dn, ignore_errors=True)
4330
+ logger.info(f"[PURGE] Removed abandoned movie folder without media: {dn}")
4331
+ except OSError:
4332
+ pass
4333
 
4334
  # Step 2: Remove blacklisted items from FILE_ID_CACHE
4335
  keys_to_purge = []
apex/ui/spotlight.html CHANGED
@@ -62,7 +62,7 @@
62
  const $ = id => document.getElementById(id);
63
  const AUTO_INTERVAL = 5000;
64
  const REFRESH_INTERVAL = 60000;
65
- const FIELDS = 'Overview,RunTimeTicks,UserData,OfficialRating,CommunityRating,SeriesName,SeriesId,ParentIndexNumber,IndexNumber,PlaybackPositionTicks,ChildCount,Status,ParentBackdropImageTags,SeriesPrimaryImageTag,SeriesThumbImageTag';
66
  const reducedMotion = window.matchMedia('(prefers-reduced-motion: reduce)');
67
  const imageCache = new Map();
68
  let items = [];
@@ -528,11 +528,32 @@
528
  function itemImageUrls(item) {
529
  const isEpisode = item.Type === 'Episode';
530
  const artworkId = isEpisode ? item.SeriesId || item.Id : item.Id;
531
- const hasBackdrop = (item.BackdropImageTags && item.BackdropImageTags.length > 0) ||
532
- (item.ParentBackdropImageTags && item.ParentBackdropImageTags.length > 0);
533
- const backdropTag = (item.BackdropImageTags && item.BackdropImageTags.length > 0)
534
- ? item.BackdropImageTags[0]
535
- : (item.ParentBackdropImageTags && item.ParentBackdropImageTags.length > 0 ? item.ParentBackdropImageTags[0] : null);
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
536
  const hasThumb = !!(item.ImageTags?.Thumb || item.SeriesThumbImageTag);
537
  const thumbTag = item.ImageTags?.Thumb || item.SeriesThumbImageTag || null;
538
  const hasPrimary = !!(item.ImageTags?.Primary || item.SeriesPrimaryImageTag);
@@ -541,7 +562,8 @@
541
  const logoTag = item.ImageTags?.Logo || null;
542
  return {
543
  primary: (hasPrimary || !item.ImageTags) ? imageUrl(artworkId, 'Primary', null, primaryTag) : null,
544
- backdrop: (hasBackdrop || !item.BackdropImageTags) ? imageUrl(artworkId, 'Backdrop', 0, backdropTag) : null,
 
545
  thumb: (hasThumb || !item.ImageTags) ? imageUrl(artworkId, 'Thumb', null, thumbTag) : null,
546
  logo: (hasLogo || !item.ImageTags) ? imageUrl(artworkId, 'Logo', null, logoTag) : null
547
  };
@@ -554,11 +576,16 @@
554
  let primaryBackdrop = false;
555
  let usedThumb = false;
556
 
557
- // 1. Try high-res 1080p widescreen backdrop
558
  if (urls.backdrop) {
559
  backdrop = await fetchImage(urls.backdrop, signal);
560
  }
561
 
 
 
 
 
 
562
  // 2. If backdrop not found, try 1080p horizontal thumb/landscape
563
  if (!backdrop && urls.thumb) {
564
  backdrop = await fetchImage(urls.thumb, signal);
 
62
  const $ = id => document.getElementById(id);
63
  const AUTO_INTERVAL = 5000;
64
  const REFRESH_INTERVAL = 60000;
65
+ const FIELDS = 'Overview,RunTimeTicks,UserData,OfficialRating,CommunityRating,SeriesName,SeriesId,ParentIndexNumber,IndexNumber,PlaybackPositionTicks,ChildCount,Status,BackdropImageTags,ParentBackdropImageTags,SeriesPrimaryImageTag,SeriesThumbImageTag';
66
  const reducedMotion = window.matchMedia('(prefers-reduced-motion: reduce)');
67
  const imageCache = new Map();
68
  let items = [];
 
528
  function itemImageUrls(item) {
529
  const isEpisode = item.Type === 'Episode';
530
  const artworkId = isEpisode ? item.SeriesId || item.Id : item.Id;
531
+ const tags = (item.BackdropImageTags && item.BackdropImageTags.length > 0)
532
+ ? item.BackdropImageTags
533
+ : (item.ParentBackdropImageTags && item.ParentBackdropImageTags.length > 0 ? item.ParentBackdropImageTags : []);
534
+ const hasBackdrop = tags.length > 0;
535
+ let backdropIndex = 0;
536
+ let backdropTag = tags[0] || null;
537
+
538
+ // Dynamically rotate thumbnails/backdrops over time
539
+ if (tags.length > 1) {
540
+ let sessionOffset = 0;
541
+ try {
542
+ let s = sessionStorage.getItem('abyss_spotlight_backdrop_seed');
543
+ if (!s) {
544
+ s = String(Math.floor(Math.random() * 100));
545
+ sessionStorage.setItem('abyss_spotlight_backdrop_seed', s);
546
+ }
547
+ sessionOffset = parseInt(s, 10) || 0;
548
+ } catch { /* ignore */ }
549
+
550
+ // 4-hour time slot + item character seed + session offset
551
+ const timeSlot = Math.floor(Date.now() / (1000 * 60 * 60 * 4));
552
+ const charSeed = (item.Id || "").split("").reduce((acc, c) => (acc * 31 + c.charCodeAt(0)) >>> 0, 0);
553
+ backdropIndex = (timeSlot + charSeed + sessionOffset) % tags.length;
554
+ backdropTag = tags[backdropIndex];
555
+ }
556
+
557
  const hasThumb = !!(item.ImageTags?.Thumb || item.SeriesThumbImageTag);
558
  const thumbTag = item.ImageTags?.Thumb || item.SeriesThumbImageTag || null;
559
  const hasPrimary = !!(item.ImageTags?.Primary || item.SeriesPrimaryImageTag);
 
562
  const logoTag = item.ImageTags?.Logo || null;
563
  return {
564
  primary: (hasPrimary || !item.ImageTags) ? imageUrl(artworkId, 'Primary', null, primaryTag) : null,
565
+ backdrop: (hasBackdrop || !item.BackdropImageTags) ? imageUrl(artworkId, 'Backdrop', backdropIndex, backdropTag) : null,
566
+ backdropFallback: (backdropIndex > 0) ? imageUrl(artworkId, 'Backdrop', 0, tags[0]) : null,
567
  thumb: (hasThumb || !item.ImageTags) ? imageUrl(artworkId, 'Thumb', null, thumbTag) : null,
568
  logo: (hasLogo || !item.ImageTags) ? imageUrl(artworkId, 'Logo', null, logoTag) : null
569
  };
 
576
  let primaryBackdrop = false;
577
  let usedThumb = false;
578
 
579
+ // 1. Try high-res 1080p widescreen backdrop (rotated)
580
  if (urls.backdrop) {
581
  backdrop = await fetchImage(urls.backdrop, signal);
582
  }
583
 
584
+ // If rotated backdrop wasn't cached or failed, fallback to index 0
585
+ if (!backdrop && urls.backdropFallback) {
586
+ backdrop = await fetchImage(urls.backdropFallback, signal);
587
+ }
588
+
589
  // 2. If backdrop not found, try 1080p horizontal thumb/landscape
590
  if (!backdrop && urls.thumb) {
591
  backdrop = await fetchImage(urls.thumb, signal);