Spaces:
Running
Running
Hermes Agent commited on
Commit ·
b119d32
1
Parent(s): 118e711
fix(apex): fix portrait detail title stacking, suppress library playall buttons, and normalize movie title deduplication
Browse files- apex/repair_jellyfin_libraries.py +69 -19
- apex/tests/test_repair_jellyfin_libraries.py +49 -0
- apex/ui/abyss.css +119 -70
- apex/ui/spotlight-loader.js +15 -8
apex/repair_jellyfin_libraries.py
CHANGED
|
@@ -853,10 +853,10 @@ def heal_missing_db_strm_paths(databases=None):
|
|
| 853 |
|
| 854 |
def deduplicate_media_strms(roots=None, databases=None):
|
| 855 |
"""
|
| 856 |
-
Deduplicates .strm files on disk and removes duplicate rows from Jellyfin BaseItems.
|
| 857 |
-
When multiple folders or .strm files point to the exact same stream URL,
|
| 858 |
-
canonical version, deletes
|
| 859 |
-
rows from BaseItems in jellyfin.db.
|
| 860 |
"""
|
| 861 |
media_roots = roots or [
|
| 862 |
"/data/jellyfin/media/Movies",
|
|
@@ -866,6 +866,10 @@ def deduplicate_media_strms(roots=None, databases=None):
|
|
| 866 |
]
|
| 867 |
db_list = databases or TARGET_DATABASES
|
| 868 |
|
|
|
|
|
|
|
|
|
|
|
|
|
| 869 |
def score_path(p: str) -> int:
|
| 870 |
score = 0
|
| 871 |
bname = os.path.basename(p)
|
|
@@ -891,6 +895,8 @@ def deduplicate_media_strms(roots=None, databases=None):
|
|
| 891 |
for root_dir in media_roots:
|
| 892 |
if not os.path.isdir(root_dir):
|
| 893 |
continue
|
|
|
|
|
|
|
| 894 |
url_map = {}
|
| 895 |
for dirpath, _, filenames in os.walk(root_dir):
|
| 896 |
for fn in filenames:
|
|
@@ -930,7 +936,43 @@ def deduplicate_media_strms(roots=None, databases=None):
|
|
| 930 |
print(f"[DEDUP] Error removing file {dup}: {e}")
|
| 931 |
all_deleted_paths.append(dup)
|
| 932 |
|
| 933 |
-
#
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 934 |
ep_map = {}
|
| 935 |
for dirpath, _, filenames in os.walk(root_dir):
|
| 936 |
for fn in filenames:
|
|
@@ -981,33 +1023,41 @@ def deduplicate_media_strms(roots=None, databases=None):
|
|
| 981 |
""")
|
| 982 |
del_count += cur.rowcount
|
| 983 |
|
| 984 |
-
# Remove duplicate BaseItems rows
|
| 985 |
cur.execute("""
|
| 986 |
-
SELECT Name,
|
| 987 |
FROM BaseItems
|
| 988 |
WHERE type LIKE '%Movie' OR type LIKE '%Series'
|
| 989 |
-
GROUP BY Name, type
|
| 990 |
-
HAVING c > 1
|
| 991 |
""")
|
| 992 |
-
|
| 993 |
-
|
| 994 |
-
|
| 995 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 996 |
def score_db_row(r):
|
| 997 |
-
p = (r[
|
|
|
|
| 998 |
s = 0
|
| 999 |
-
if os.path.exists(r[
|
|
|
|
|
|
|
| 1000 |
if "spider-man" in p: s += 50
|
| 1001 |
if "shang-chi" in p: s += 50
|
| 1002 |
if "china-town" in p: s += 50
|
| 1003 |
if "," in p or "." in p: s += 10
|
| 1004 |
s += len(p)
|
| 1005 |
return s
|
| 1006 |
-
|
| 1007 |
-
for redundant in
|
| 1008 |
-
print(f"[DEDUP] Removing duplicate BaseItems row by name: '{
|
| 1009 |
cur.execute("DELETE FROM BaseItems WHERE Id = ?", (redundant[0],))
|
| 1010 |
-
del_count +=
|
| 1011 |
conn.commit()
|
| 1012 |
conn.close()
|
| 1013 |
if del_count > 0:
|
|
|
|
| 853 |
|
| 854 |
def deduplicate_media_strms(roots=None, databases=None):
|
| 855 |
"""
|
| 856 |
+
Deduplicates .strm files and folders on disk, and removes duplicate rows from Jellyfin BaseItems.
|
| 857 |
+
When multiple folders or .strm files point to the exact same stream URL, or share the exact same
|
| 858 |
+
normalized movie/show title, picks the canonical version, deletes duplicate folders/files from disk,
|
| 859 |
+
and removes redundant rows from BaseItems in jellyfin.db.
|
| 860 |
"""
|
| 861 |
media_roots = roots or [
|
| 862 |
"/data/jellyfin/media/Movies",
|
|
|
|
| 866 |
]
|
| 867 |
db_list = databases or TARGET_DATABASES
|
| 868 |
|
| 869 |
+
def normalize_title(s: str) -> str:
|
| 870 |
+
cleaned = re.sub(r"\s*\(\s*\d{4}\s*\)\s*", "", s or "")
|
| 871 |
+
return re.sub(r"[^a-z0-9]", "", cleaned.lower())
|
| 872 |
+
|
| 873 |
def score_path(p: str) -> int:
|
| 874 |
score = 0
|
| 875 |
bname = os.path.basename(p)
|
|
|
|
| 895 |
for root_dir in media_roots:
|
| 896 |
if not os.path.isdir(root_dir):
|
| 897 |
continue
|
| 898 |
+
|
| 899 |
+
# 1. Deduplicate by identical stream URL inside .strm files
|
| 900 |
url_map = {}
|
| 901 |
for dirpath, _, filenames in os.walk(root_dir):
|
| 902 |
for fn in filenames:
|
|
|
|
| 936 |
print(f"[DEDUP] Error removing file {dup}: {e}")
|
| 937 |
all_deleted_paths.append(dup)
|
| 938 |
|
| 939 |
+
# 2. Deduplicate media folders matching the same normalized title (e.g. Movies/Shang-Chi (2021) vs Movies/Shang-Chi)
|
| 940 |
+
folder_groups = {}
|
| 941 |
+
try:
|
| 942 |
+
for entry in os.scandir(root_dir):
|
| 943 |
+
if entry.is_dir():
|
| 944 |
+
norm_k = normalize_title(entry.name)
|
| 945 |
+
if norm_k:
|
| 946 |
+
folder_groups.setdefault(norm_k, []).append(entry.path)
|
| 947 |
+
except Exception:
|
| 948 |
+
pass
|
| 949 |
+
|
| 950 |
+
for norm_k, fpaths in folder_groups.items():
|
| 951 |
+
if len(fpaths) <= 1:
|
| 952 |
+
continue
|
| 953 |
+
def score_folder(p: str) -> int:
|
| 954 |
+
s = score_path(p)
|
| 955 |
+
try:
|
| 956 |
+
for sub_f in os.listdir(p):
|
| 957 |
+
if sub_f.lower().endswith(".strm") and os.path.getsize(os.path.join(p, sub_f)) > 0:
|
| 958 |
+
s += 100
|
| 959 |
+
break
|
| 960 |
+
except Exception:
|
| 961 |
+
pass
|
| 962 |
+
return s
|
| 963 |
+
fpaths.sort(key=score_folder, reverse=True)
|
| 964 |
+
canon_folder = fpaths[0]
|
| 965 |
+
for dup_folder in fpaths[1:]:
|
| 966 |
+
if dup_folder in all_deleted_dirs:
|
| 967 |
+
continue
|
| 968 |
+
print(f"[DEDUP] Removing duplicate media folder by title: '{dup_folder}' (retaining '{canon_folder}')")
|
| 969 |
+
try:
|
| 970 |
+
shutil.rmtree(dup_folder, ignore_errors=True)
|
| 971 |
+
all_deleted_dirs.append(dup_folder)
|
| 972 |
+
except Exception as e:
|
| 973 |
+
print(f"[DEDUP] Error removing folder {dup_folder}: {e}")
|
| 974 |
+
|
| 975 |
+
# 3. Detect duplicate episodes in the same Season folder matching SxxExx
|
| 976 |
ep_map = {}
|
| 977 |
for dirpath, _, filenames in os.walk(root_dir):
|
| 978 |
for fn in filenames:
|
|
|
|
| 1023 |
""")
|
| 1024 |
del_count += cur.rowcount
|
| 1025 |
|
| 1026 |
+
# Remove duplicate BaseItems rows matching same normalized name and type
|
| 1027 |
cur.execute("""
|
| 1028 |
+
SELECT Id, Name, Path, type
|
| 1029 |
FROM BaseItems
|
| 1030 |
WHERE type LIKE '%Movie' OR type LIKE '%Series'
|
|
|
|
|
|
|
| 1031 |
""")
|
| 1032 |
+
all_media_items = cur.fetchall()
|
| 1033 |
+
title_groups = {}
|
| 1034 |
+
for item_id, item_name, item_path, item_type in all_media_items:
|
| 1035 |
+
if not item_name:
|
| 1036 |
+
continue
|
| 1037 |
+
norm_key = (normalize_title(item_name), item_type)
|
| 1038 |
+
title_groups.setdefault(norm_key, []).append((item_id, item_name, item_path))
|
| 1039 |
+
|
| 1040 |
+
for (norm_title, itype), candidates in title_groups.items():
|
| 1041 |
+
if len(candidates) <= 1:
|
| 1042 |
+
continue
|
| 1043 |
def score_db_row(r):
|
| 1044 |
+
p = (r[2] or "").lower()
|
| 1045 |
+
name = (r[1] or "")
|
| 1046 |
s = 0
|
| 1047 |
+
if os.path.exists(r[2] or ""): s += 200
|
| 1048 |
+
if re.search(r"\(\d{4}\)", name): s += 30
|
| 1049 |
+
if "-" in name: s += 10
|
| 1050 |
if "spider-man" in p: s += 50
|
| 1051 |
if "shang-chi" in p: s += 50
|
| 1052 |
if "china-town" in p: s += 50
|
| 1053 |
if "," in p or "." in p: s += 10
|
| 1054 |
s += len(p)
|
| 1055 |
return s
|
| 1056 |
+
candidates.sort(key=score_db_row, reverse=True)
|
| 1057 |
+
for redundant in candidates[1:]:
|
| 1058 |
+
print(f"[DEDUP] Removing duplicate BaseItems row by normalized name: '{redundant[1]}' (Id={redundant[0]}, Path={redundant[2]})")
|
| 1059 |
cur.execute("DELETE FROM BaseItems WHERE Id = ?", (redundant[0],))
|
| 1060 |
+
del_count += 1
|
| 1061 |
conn.commit()
|
| 1062 |
conn.close()
|
| 1063 |
if del_count > 0:
|
apex/tests/test_repair_jellyfin_libraries.py
CHANGED
|
@@ -756,3 +756,52 @@ def test_deduplicate_media_strms_removes_duplicate_folders_and_db_rows(tmp_path)
|
|
| 756 |
assert str(canon_strm) in paths
|
| 757 |
assert str(dup_strm) not in paths
|
| 758 |
assert str(dup_dir) not in paths
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 756 |
assert str(canon_strm) in paths
|
| 757 |
assert str(dup_strm) not in paths
|
| 758 |
assert str(dup_dir) not in paths
|
| 759 |
+
|
| 760 |
+
def test_deduplicate_media_strms_normalizes_title_variants(tmp_path):
|
| 761 |
+
"""Ensure duplicate movie folders with differing punctuation, case, or year are deduplicated."""
|
| 762 |
+
movies_root = tmp_path / "Movies"
|
| 763 |
+
movies_root.mkdir()
|
| 764 |
+
|
| 765 |
+
# Folder 1: Spider-Man Far From Home (2019)
|
| 766 |
+
dir1 = movies_root / "Spider-Man Far From Home (2019)"
|
| 767 |
+
dir1.mkdir()
|
| 768 |
+
strm1 = dir1 / "Spider-Man Far From Home (2019).strm"
|
| 769 |
+
strm1.write_text("http://127.0.0.1:8082/stream/111", encoding="utf-8")
|
| 770 |
+
|
| 771 |
+
# Folder 2: Spider-Man: Far from Home
|
| 772 |
+
dir2 = movies_root / "Spider-Man: Far from Home"
|
| 773 |
+
dir2.mkdir()
|
| 774 |
+
strm2 = dir2 / "Spider-Man Far from Home.strm"
|
| 775 |
+
strm2.write_text("http://127.0.0.1:8082/stream/222", encoding="utf-8")
|
| 776 |
+
|
| 777 |
+
# DB with both rows
|
| 778 |
+
db_file = tmp_path / "jellyfin.db"
|
| 779 |
+
conn = sqlite3.connect(str(db_file))
|
| 780 |
+
cur = conn.cursor()
|
| 781 |
+
cur.execute("""
|
| 782 |
+
CREATE TABLE BaseItems (
|
| 783 |
+
Id TEXT PRIMARY KEY,
|
| 784 |
+
Path TEXT,
|
| 785 |
+
Name TEXT,
|
| 786 |
+
type TEXT
|
| 787 |
+
)
|
| 788 |
+
""")
|
| 789 |
+
cur.execute("INSERT INTO BaseItems VALUES ('id_canon', ?, 'Spider-Man: Far From Home', 'MediaBrowser.Controller.Entities.Movies.Movie')", (str(strm1),))
|
| 790 |
+
cur.execute("INSERT INTO BaseItems VALUES ('id_dup', ?, 'Spider-Man: Far from Home', 'MediaBrowser.Controller.Entities.Movies.Movie')", (str(strm2),))
|
| 791 |
+
conn.commit()
|
| 792 |
+
conn.close()
|
| 793 |
+
|
| 794 |
+
deduplicate_media_strms(roots=[str(movies_root)], databases=[str(db_file)])
|
| 795 |
+
|
| 796 |
+
# Exactly one folder and one DB item should remain
|
| 797 |
+
assert dir1.exists() != dir2.exists()
|
| 798 |
+
|
| 799 |
+
conn = sqlite3.connect(str(db_file))
|
| 800 |
+
cur = conn.cursor()
|
| 801 |
+
cur.execute("SELECT Id FROM BaseItems")
|
| 802 |
+
remaining_ids = [r[0] for r in cur.fetchall()]
|
| 803 |
+
conn.close()
|
| 804 |
+
|
| 805 |
+
assert len(remaining_ids) == 1
|
| 806 |
+
assert 'id_canon' in remaining_ids
|
| 807 |
+
|
apex/ui/abyss.css
CHANGED
|
@@ -3257,9 +3257,11 @@ div[role="main"] {
|
|
| 3257 |
/* 9. Action Buttons Across ALL Settings & Dashboard */
|
| 3258 |
.MuiButton-contained,
|
| 3259 |
.MuiButton-containedPrimary,
|
| 3260 |
-
|
| 3261 |
-
.
|
| 3262 |
-
.settingsContainer button.raised
|
|
|
|
|
|
|
| 3263 |
background: rgba(255, 255, 255, 0.95) !important;
|
| 3264 |
color: #0e0e11 !important;
|
| 3265 |
border-radius: 50px !important;
|
|
@@ -3278,6 +3280,42 @@ div[role="main"] {
|
|
| 3278 |
box-shadow: 0 4px 16px rgba(255, 255, 255, 0.2) !important;
|
| 3279 |
}
|
| 3280 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 3281 |
/* Restart button */
|
| 3282 |
.MuiButton-root:has([data-testid*="Restart"]),
|
| 3283 |
button[id*="Restart"],
|
|
@@ -4566,78 +4604,89 @@ header .pageTitle,
|
|
| 4566 |
|
| 4567 |
|
| 4568 |
/* ========================================================
|
| 4569 |
-
DETAIL PAGE:
|
| 4570 |
-
|
| 4571 |
-
Ensures title never breaks letter-by-letter in landscape or portrait
|
| 4572 |
======================================================== */
|
| 4573 |
-
|
| 4574 |
-
|
| 4575 |
-
.layout-
|
| 4576 |
-
|
| 4577 |
-
|
| 4578 |
-
|
| 4579 |
-
|
| 4580 |
-
|
| 4581 |
-
|
| 4582 |
-
|
| 4583 |
-
|
| 4584 |
-
|
| 4585 |
-
margin-top: 0 !important;
|
| 4586 |
-
box-sizing: border-box !important;
|
| 4587 |
-
width: 100% !important;
|
| 4588 |
-
max-width: 100% !important;
|
| 4589 |
-
}
|
| 4590 |
|
| 4591 |
-
#itemDetailPage .
|
| 4592 |
-
.
|
| 4593 |
-
|
| 4594 |
-
|
| 4595 |
-
|
| 4596 |
-
|
| 4597 |
-
|
| 4598 |
-
|
| 4599 |
-
z-index: 2 !important;
|
| 4600 |
-
}
|
| 4601 |
|
| 4602 |
-
|
| 4603 |
-
.layout-mobile #itemDetailPage .detailImageContainer
|
| 4604 |
-
|
| 4605 |
-
|
| 4606 |
-
|
| 4607 |
-
|
| 4608 |
-
|
| 4609 |
-
|
| 4610 |
-
|
| 4611 |
-
|
| 4612 |
-
max-width: 100% !important;
|
| 4613 |
-
border-radius: 12px !important;
|
| 4614 |
-
}
|
| 4615 |
|
| 4616 |
-
#itemDetailPage .
|
| 4617 |
-
.layout-mobile #itemDetailPage .
|
| 4618 |
-
|
| 4619 |
-
|
| 4620 |
-
|
| 4621 |
-
|
| 4622 |
-
|
| 4623 |
-
|
| 4624 |
-
|
| 4625 |
-
|
| 4626 |
-
padding-left: 0 !important;
|
| 4627 |
-
margin-left: 0 !important;
|
| 4628 |
-
}
|
| 4629 |
|
| 4630 |
-
#itemDetailPage .
|
| 4631 |
-
#itemDetailPage .itemName,
|
| 4632 |
-
#itemDetailPage .
|
| 4633 |
-
|
| 4634 |
-
|
| 4635 |
-
|
| 4636 |
-
|
| 4637 |
-
|
| 4638 |
-
|
| 4639 |
-
|
| 4640 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 4641 |
}
|
| 4642 |
|
| 4643 |
/* Universal Abyss Spotlight Hero Banner Iframe */
|
|
|
|
| 3257 |
/* 9. Action Buttons Across ALL Settings & Dashboard */
|
| 3258 |
.MuiButton-contained,
|
| 3259 |
.MuiButton-containedPrimary,
|
| 3260 |
+
#dashboardPage button.raised:not([class*="delete"]):not([class*="cancel"]):not([class*="warn"]):not([class*="restart"]):not([class*="shutdown"]):not([class*="button-flat"]),
|
| 3261 |
+
.dashboardPage button.raised:not([class*="delete"]):not([class*="cancel"]):not([class*="warn"]):not([class*="restart"]):not([class*="shutdown"]):not([class*="button-flat"]),
|
| 3262 |
+
.settingsContainer button.raised,
|
| 3263 |
+
.formDialog button.raised,
|
| 3264 |
+
.type-interior .button-submit {
|
| 3265 |
background: rgba(255, 255, 255, 0.95) !important;
|
| 3266 |
color: #0e0e11 !important;
|
| 3267 |
border-radius: 50px !important;
|
|
|
|
| 3280 |
box-shadow: 0 4px 16px rgba(255, 255, 255, 0.2) !important;
|
| 3281 |
}
|
| 3282 |
|
| 3283 |
+
|
| 3284 |
+
/* Suppress floating Play All and Shuffle All in library collection views */
|
| 3285 |
+
.page:not(#itemDetailPage):not(#videoOsdPage) .btnPlay,
|
| 3286 |
+
.page:not(#itemDetailPage):not(#videoOsdPage) .btnShuffle,
|
| 3287 |
+
.viewSettings .btnPlay,
|
| 3288 |
+
.viewSettings .btnShuffle,
|
| 3289 |
+
.pageTitleWithDefaultButton .btnPlay,
|
| 3290 |
+
.pageTitleWithDefaultButton .btnShuffle,
|
| 3291 |
+
button[data-action="playall"],
|
| 3292 |
+
button[data-action="shuffleall"],
|
| 3293 |
+
.mainTabsContainer .btnPlay,
|
| 3294 |
+
.mainTabsContainer .btnShuffle,
|
| 3295 |
+
.headerRight .btnPlay,
|
| 3296 |
+
.headerRight .btnShuffle {
|
| 3297 |
+
display: none !important;
|
| 3298 |
+
visibility: hidden !important;
|
| 3299 |
+
width: 0 !important;
|
| 3300 |
+
height: 0 !important;
|
| 3301 |
+
padding: 0 !important;
|
| 3302 |
+
margin: 0 !important;
|
| 3303 |
+
pointer-events: none !important;
|
| 3304 |
+
}
|
| 3305 |
+
|
| 3306 |
+
/* Hidden duplicate cards */
|
| 3307 |
+
.card.apex-duplicate-hidden {
|
| 3308 |
+
display: none !important;
|
| 3309 |
+
visibility: hidden !important;
|
| 3310 |
+
width: 0 !important;
|
| 3311 |
+
height: 0 !important;
|
| 3312 |
+
margin: 0 !important;
|
| 3313 |
+
padding: 0 !important;
|
| 3314 |
+
flex: 0 0 0 !important;
|
| 3315 |
+
pointer-events: none !important;
|
| 3316 |
+
overflow: hidden !important;
|
| 3317 |
+
}
|
| 3318 |
+
|
| 3319 |
/* Restart button */
|
| 3320 |
.MuiButton-root:has([data-testid*="Restart"]),
|
| 3321 |
button[id*="Restart"],
|
|
|
|
| 4604 |
|
| 4605 |
|
| 4606 |
/* ========================================================
|
| 4607 |
+
DETAIL PAGE: MOBILE PORTRAIT & DEFAULT CLEAN LAYOUT
|
| 4608 |
+
Full-width title, zero text breaking or vertical letter stacking
|
|
|
|
| 4609 |
======================================================== */
|
| 4610 |
+
@media (orientation: portrait) {
|
| 4611 |
+
#itemDetailPage .detailRibbon,
|
| 4612 |
+
.layout-mobile #itemDetailPage .detailRibbon {
|
| 4613 |
+
display: flex !important;
|
| 4614 |
+
flex-direction: column !important;
|
| 4615 |
+
align-items: stretch !important;
|
| 4616 |
+
width: 100% !important;
|
| 4617 |
+
max-width: 100% !important;
|
| 4618 |
+
padding: 16px 20px !important;
|
| 4619 |
+
gap: 12px !important;
|
| 4620 |
+
box-sizing: border-box !important;
|
| 4621 |
+
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 4622 |
|
| 4623 |
+
#itemDetailPage .infoWrapper,
|
| 4624 |
+
.layout-mobile #itemDetailPage .infoWrapper {
|
| 4625 |
+
display: flex !important;
|
| 4626 |
+
flex-direction: column !important;
|
| 4627 |
+
align-items: stretch !important;
|
| 4628 |
+
width: 100% !important;
|
| 4629 |
+
max-width: 100% !important;
|
| 4630 |
+
}
|
|
|
|
|
|
|
| 4631 |
|
| 4632 |
+
/* On mobile portrait, hide redundant small poster so full-width backdrop and clean title shine */
|
| 4633 |
+
.layout-mobile #itemDetailPage .detailImageContainer {
|
| 4634 |
+
display: none !important;
|
| 4635 |
+
visibility: hidden !important;
|
| 4636 |
+
width: 0 !important;
|
| 4637 |
+
height: 0 !important;
|
| 4638 |
+
margin: 0 !important;
|
| 4639 |
+
padding: 0 !important;
|
| 4640 |
+
pointer-events: none !important;
|
| 4641 |
+
}
|
|
|
|
|
|
|
|
|
|
| 4642 |
|
| 4643 |
+
#itemDetailPage .nameContainer,
|
| 4644 |
+
.layout-mobile #itemDetailPage .nameContainer {
|
| 4645 |
+
display: flex !important;
|
| 4646 |
+
flex-direction: row !important;
|
| 4647 |
+
align-items: center !important;
|
| 4648 |
+
justify-content: space-between !important;
|
| 4649 |
+
width: 100% !important;
|
| 4650 |
+
max-width: 100% !important;
|
| 4651 |
+
margin-bottom: 8px !important;
|
| 4652 |
+
}
|
|
|
|
|
|
|
|
|
|
| 4653 |
|
| 4654 |
+
#itemDetailPage .itemName,
|
| 4655 |
+
.layout-mobile #itemDetailPage .itemName,
|
| 4656 |
+
#itemDetailPage h1.itemName {
|
| 4657 |
+
display: block !important;
|
| 4658 |
+
width: 100% !important;
|
| 4659 |
+
max-width: 100% !important;
|
| 4660 |
+
font-size: 1.65rem !important;
|
| 4661 |
+
font-weight: 700 !important;
|
| 4662 |
+
line-height: 1.25 !important;
|
| 4663 |
+
color: #ffffff !important;
|
| 4664 |
+
white-space: normal !important;
|
| 4665 |
+
word-break: normal !important;
|
| 4666 |
+
overflow-wrap: break-word !important;
|
| 4667 |
+
letter-spacing: normal !important;
|
| 4668 |
+
}
|
| 4669 |
+
|
| 4670 |
+
#itemDetailPage .itemMiscInfo,
|
| 4671 |
+
.layout-mobile #itemDetailPage .itemMiscInfo {
|
| 4672 |
+
display: flex !important;
|
| 4673 |
+
flex-direction: row !important;
|
| 4674 |
+
align-items: center !important;
|
| 4675 |
+
flex-wrap: wrap !important;
|
| 4676 |
+
gap: 8px !important;
|
| 4677 |
+
width: 100% !important;
|
| 4678 |
+
margin-bottom: 12px !important;
|
| 4679 |
+
}
|
| 4680 |
+
|
| 4681 |
+
#itemDetailPage .mainDetailButtons,
|
| 4682 |
+
.layout-mobile #itemDetailPage .mainDetailButtons {
|
| 4683 |
+
display: flex !important;
|
| 4684 |
+
flex-direction: row !important;
|
| 4685 |
+
align-items: center !important;
|
| 4686 |
+
flex-wrap: wrap !important;
|
| 4687 |
+
gap: 10px !important;
|
| 4688 |
+
width: 100% !important;
|
| 4689 |
+
}
|
| 4690 |
}
|
| 4691 |
|
| 4692 |
/* Universal Abyss Spotlight Hero Banner Iframe */
|
apex/ui/spotlight-loader.js
CHANGED
|
@@ -901,21 +901,28 @@ function findHomeTab() {
|
|
| 901 |
|
| 902 |
function deduplicateDomCards() {
|
| 903 |
safe(function () {
|
| 904 |
-
var containers = document.querySelectorAll(
|
| 905 |
for (var i = 0; i < containers.length; i++) {
|
| 906 |
var container = containers[i];
|
| 907 |
-
var cards = container.
|
|
|
|
| 908 |
var seenTitles = {};
|
| 909 |
for (var j = 0; j < cards.length; j++) {
|
| 910 |
var card = cards[j];
|
| 911 |
-
if (!card.classList || !card.classList.contains(
|
| 912 |
-
var titleEl = card.querySelector(
|
| 913 |
-
var
|
| 914 |
-
var normTitle =
|
| 915 |
if (!normTitle) continue;
|
| 916 |
if (seenTitles[normTitle]) {
|
| 917 |
-
card.
|
| 918 |
-
card.style.
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 919 |
} else {
|
| 920 |
seenTitles[normTitle] = true;
|
| 921 |
}
|
|
|
|
| 901 |
|
| 902 |
function deduplicateDomCards() {
|
| 903 |
safe(function () {
|
| 904 |
+
var containers = document.querySelectorAll(".itemsContainer");
|
| 905 |
for (var i = 0; i < containers.length; i++) {
|
| 906 |
var container = containers[i];
|
| 907 |
+
var cards = container.querySelectorAll(":scope > .card, :scope > div > .card");
|
| 908 |
+
if (!cards || !cards.length) cards = container.children;
|
| 909 |
var seenTitles = {};
|
| 910 |
for (var j = 0; j < cards.length; j++) {
|
| 911 |
var card = cards[j];
|
| 912 |
+
if (!card.classList || !card.classList.contains("card")) continue;
|
| 913 |
+
var titleEl = card.querySelector(".cardText-first") || card.querySelector(".cardText:not(.cardText-secondary)") || card.querySelector(".cardTextCentered") || card.querySelector(".cardText");
|
| 914 |
+
var rawTitle = (titleEl ? titleEl.textContent : (card.getAttribute("aria-label") || "")).trim().toLowerCase();
|
| 915 |
+
var normTitle = rawTitle.replace(/\s*\(\s*\d{4}\s*\)\s*/g, "").replace(/[^a-z0-9]/g, "");
|
| 916 |
if (!normTitle) continue;
|
| 917 |
if (seenTitles[normTitle]) {
|
| 918 |
+
card.classList.add("apex-duplicate-hidden");
|
| 919 |
+
card.style.setProperty("display", "none", "important");
|
| 920 |
+
card.style.setProperty("visibility", "hidden", "important");
|
| 921 |
+
card.style.setProperty("width", "0px", "important");
|
| 922 |
+
card.style.setProperty("height", "0px", "important");
|
| 923 |
+
card.style.setProperty("flex", "0 0 0px", "important");
|
| 924 |
+
card.style.setProperty("margin", "0px", "important");
|
| 925 |
+
card.style.setProperty("padding", "0px", "important");
|
| 926 |
} else {
|
| 927 |
seenTitles[normTitle] = true;
|
| 928 |
}
|