This commit is contained in:
2026-03-29 20:52:57 -05:00
parent a97c3a5b57
commit cf155183f2
102 changed files with 55674 additions and 0 deletions

BIN
.DS_Store vendored Normal file

Binary file not shown.

BIN
animex/.DS_Store vendored Normal file

Binary file not shown.

BIN
animex/README.md Normal file

Binary file not shown.

BIN
animex/Resources/.DS_Store vendored Normal file

Binary file not shown.

Binary file not shown.

After

Width:  |  Height:  |  Size: 360 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 569 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 317 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 35 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 57 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 182 KiB

View File

@@ -0,0 +1,3 @@
<svg width="33" height="29" viewBox="0 0 33 29" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M10.1507 24.6181C13.4752 24.6181 15.737 26.2917 16.0776 27.0829C16.0912 27.1286 16.1185 27.159 16.1594 27.1894V3.74294C15.3146 2.93654 12.9983 1.73455 10.1643 1.73455C6.68992 1.73455 3.46078 3.71251 3.4744 4.79279V26.8547C3.4744 27.0981 3.62428 27.2655 3.84228 27.2655C4.04665 27.2655 4.19653 27.1894 4.25103 27.0829C4.59166 26.307 6.8398 24.6181 10.1507 24.6181ZM22.8493 24.6181C26.1738 24.6181 28.422 26.307 28.7626 27.0829C28.8035 27.1894 28.9533 27.2655 29.1577 27.2655C29.3757 27.2655 29.5256 27.0981 29.5256 26.8547V4.79279C29.5392 3.71251 26.3237 1.73455 22.8357 1.73455C20.0017 1.73455 17.699 2.93654 16.8406 3.74294V27.1894C16.8951 27.159 16.9088 27.1286 16.9224 27.0829C17.263 26.2917 19.5248 24.6181 22.8493 24.6181Z" fill="#5B574C" stroke="#B6521B"/>
</svg>

After

Width:  |  Height:  |  Size: 874 B

View File

@@ -0,0 +1,3 @@
<svg width="58" height="64" viewBox="0 0 58 64" fill="none" xmlns="http://www.w3.org/2000/svg">
<rect width="58" height="64" rx="9" fill="#D9D9D9"/>
</svg>

After

Width:  |  Height:  |  Size: 203 B

View File

@@ -0,0 +1,3 @@
<svg width="58" height="24" viewBox="0 0 58 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<rect width="58" height="24" rx="12" fill="#22221C"/>
</svg>

After

Width:  |  Height:  |  Size: 204 B

View File

@@ -0,0 +1,4 @@
<svg width="23" height="32" viewBox="0 0 23 32" fill="none" xmlns="http://www.w3.org/2000/svg">
<g clip-path="url(#clip0_12_142_item1)"><path d="M4.43147 31.0806C4.56956 31.0806 4.62135 31.0016 4.72491 30.9029L18.7066 16.5629C18.862 16.3852 18.9828 16.2074 18.9828 15.9901C18.9828 15.7728 18.8792 15.6148 18.7239 15.4568L4.75944 1.11687C4.63861 1.01811 4.56956 0.919348 4.43147 0.919348C4.20707 0.919348 4.0172 1.13662 4.0172 1.41315C4.0172 1.53166 4.03446 1.66992 4.12076 1.74893L17.9471 16.0099L4.12076 30.2313C4.03446 30.3301 4.0172 30.4683 4.0172 30.5868C4.0172 30.8634 4.20707 31.0806 4.43147 31.0806Z" fill="#878472" stroke="#878472" stroke-width="3"/></g>
<defs><clipPath id="clip0_12_142_item1"><rect width="23" height="32" fill="white"/></clipPath></defs>
</svg>

After

Width:  |  Height:  |  Size: 843 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.1 KiB

885
animex/Resources/manga.css Normal file
View File

@@ -0,0 +1,885 @@
:root {
/* Accent and Palette */
--brand-accent: #ff9500;
--background-primary: #0a0a0a;
--background-secondary: #161616;
--background-tertiary: #202020; /* Added for consistency in related cards */
--text-primary: #eaeaea;
--text-secondary: #999999;
--text-muted: #666; /* Added for info messages */
--border-color: #2a2a2a;
/* Effects */
--brand-glow: rgba(255, 149, 0, 0.5);
--shadow-color: rgba(0, 0, 0, 0.6);
/* Animation and Sizing */
--transition-duration: 0.3s;
--border-radius: 8px;
}
*,
*::before,
*::after {
margin: 0;
padding: 0;
box-sizing: border-box;
}
html {
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
}
body {
background-color: var(--background-primary);
color: var(--text-primary);
font-family: "Inter", -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto,
Helvetica, Arial, sans-serif;
line-height: 1.6;
}
#manga-container {
width: 100%;
min-height: 100vh;
overflow-x: hidden;
}
/* --- HERO SECTION --- */
#hero-section {
position: relative;
display: flex;
align-items: center;
justify-content: center;
height: 60vh;
min-height: 450px;
padding: 0 5%;
background-color: var(--background-tertiary);
background-size: cover;
background-position: center;
transition: background-image var(--transition-duration) ease-in-out;
}
.hero-overlay {
position: absolute;
inset: 0;
background: linear-gradient(
to top,
var(--background-primary) 20%,
rgba(10, 10, 10, 0.7) 50%,
transparent 100%
);
z-index: 1;
}
.back-btn {
position: absolute;
top: 20px;
left: 20px;
z-index: 10;
display: flex;
align-items: center;
justify-content: center;
width: 44px;
height: 44px;
border-radius: 50%;
background: rgba(30, 30, 30, 0.7);
border: 1px solid var(--border-color);
color: var(--text-primary);
font-size: 1.2rem;
cursor: pointer;
transition: background-color var(--transition-duration) ease,
transform var(--transition-duration) ease;
}
.back-btn:hover {
background-color: var(--background-secondary);
transform: scale(1.05);
}
.vertical-title-jp {
position: absolute;
top: 50%;
right: 30px;
transform: translateY(-50%);
z-index: 2;
writing-mode: vertical-rl;
font-size: clamp(2.5rem, 6vw, 4rem);
font-weight: 700;
color: rgba(255, 255, 255, 0.15);
letter-spacing: 6px;
user-select: none;
opacity: 0;
transition: opacity 0.5s ease 0.2s;
}
.vertical-title-jp.loaded {
opacity: 1;
}
.cover-art-container {
position: relative;
z-index: 3;
height: 65%;
max-height: 420px;
aspect-ratio: 2 / 3;
transform: translateY(20px);
transition: transform 0.5s ease;
}
.cover-art-container.loaded {
transform: translateY(30px);
}
#manga-cover {
width: 100%;
height: 100%;
object-fit: cover;
border-radius: var(--border-radius);
box-shadow: 0 20px 40px -10px var(--shadow-color);
}
/* --- CONTENT SHEET --- */
#content-sheet {
position: relative;
z-index: 4;
max-width: 1200px;
min-height: 50vh;
margin: -50px auto 0;
padding: 2rem 5%;
background: transparent;
border-radius: 24px 24px 0 0;
}
.manga-title-en {
font-size: clamp(2.2rem, 6vw, 3rem);
font-weight: 800;
line-height: 1.1;
text-align: left;
}
.manga-title-jp {
margin-bottom: 2rem;
font-size: clamp(1.1rem, 3vw, 1.3rem);
font-weight: 400;
color: var(--text-secondary);
text-align: left;
}
.genres-container {
display: flex;
flex-wrap: wrap;
justify-content: flex-start;
gap: 10px;
margin-bottom: 2.5rem;
}
.genre-tag {
padding: 6px 14px;
border: 1px solid var(--border-color);
border-radius: 4px;
background: transparent;
color: var(--text-secondary);
font-size: 0.9rem;
font-weight: 600;
transition: color var(--transition-duration) ease,
border-color var(--transition-duration) ease;
}
.genre-tag:hover {
color: var(--text-primary);
border-color: var(--brand-accent);
}
.synopsis-container {
margin: 0 0 3rem 0;
}
.synopsis-container h3 {
padding-bottom: 0.5rem;
margin-bottom: 1rem;
border-bottom: 2px solid var(--border-color);
font-size: 1.2rem;
font-weight: 600;
color: var(--text-primary);
}
#manga-synopsis {
color: var(--text-secondary);
font-size: 1.05rem;
line-height: 1.8;
}
/* --- TABS SYSTEM --- */
.tabs-section {
margin: 0 auto;
}
.tabs-container {
display: flex;
gap: 1.5rem;
margin-bottom: 2rem;
border-bottom: 1px solid var(--border-color);
}
.tab-btn {
position: relative;
padding: 1rem 0.25rem;
border: none;
background: none;
color: var(--text-secondary);
font-size: 1.1rem;
font-weight: 600;
cursor: pointer;
transition: color var(--transition-duration) ease;
}
.tab-btn::after {
content: "";
position: absolute;
bottom: -1px;
left: 0;
right: 0;
height: 2px;
background-color: var(--brand-accent);
transform: scaleX(0);
transition: transform var(--transition-duration)
cubic-bezier(0.19, 1, 0.22, 1);
}
.tab-btn:hover {
color: var(--text-primary);
}
.tab-btn.active {
color: var(--text-primary);
}
.tab-btn.active::after {
transform: scaleX(1);
}
.tab-panel {
display: none;
animation: fadeIn 0.4s ease;
}
.tab-panel.active {
display: flex;
flex-direction: column;
}
@keyframes fadeIn {
from {
opacity: 0;
}
to {
opacity: 1;
}
}
/* --- CHAPTER LIST --- */
#chapter-list {
display: flex;
flex-direction: column;
gap: 12px;
margin: 0;
padding: 0;
list-style: none;
}
.chapter-item {
display: flex;
align-items: stretch;
border: 1px solid var(--border-color);
border-radius: var(--border-radius);
background-color: var(--background-secondary);
overflow: hidden;
cursor: pointer;
transition: transform 0.2s ease-in-out, border-color 0.2s ease-in-out,
box-shadow 0.2s ease-in-out;
}
.chapter-item:hover {
transform: translateY(-4px) scale(1.02);
border-color: var(--brand-accent);
box-shadow: 0 8px 25px -5px var(--shadow-color);
}
.chapter-item.is-read .chapter-details .chapter-title {
color: var(--text-secondary);
}
.chapter-item.is-read:hover .chapter-details .chapter-title {
color: var(--text-primary);
}
.chapter-thumbnail {
position: relative;
display: flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
width: 180px;
background-color: #333;
background-size: cover;
background-position: center;
}
.chapter-thumbnail .chapter-number-display {
z-index: 2;
font-size: 1.8rem;
font-weight: 800;
color: rgba(255, 255, 255, 0.8);
text-shadow: 0 2px 4px rgba(0, 0, 0, 0.5);
}
.chapter-thumbnail::after {
content: "\f04b";
font-family: "Font Awesome 6 Free";
font-weight: 900;
position: absolute;
top: 50%;
left: 50%;
transform: translate(-50%, -50%) scale(0.8);
z-index: 3;
font-size: 32px;
color: rgba(255, 255, 255, 0.9);
opacity: 0;
transition: opacity 0.2s ease-out, transform 0.2s ease-out;
}
.chapter-item:hover .chapter-thumbnail::after {
opacity: 1;
transform: translate(-50%, -50%) scale(1);
}
.chapter-item .chapter-thumbnail::before {
content: "";
position: absolute;
inset: 0;
z-index: 1;
background: radial-gradient(
circle at center,
rgba(0, 0, 0, 0.3) 0%,
rgba(0, 0, 0, 0.7) 100%
);
opacity: 0;
transition: opacity 0.2s ease-out;
}
.chapter-item:hover .chapter-thumbnail::before {
opacity: 1;
}
.read-progress {
position: absolute;
bottom: 0;
left: 0;
z-index: 4;
width: 0;
height: 5px;
background-color: var(--brand-accent);
transition: width 0.4s ease;
}
.chapter-item.is-read .read-progress {
width: 100%;
}
.chapter-details {
display: flex;
flex-direction: column;
justify-content: center;
flex-grow: 1;
gap: 6px;
padding: 12px 20px;
}
.chapter-title {
display: -webkit-box;
-webkit-line-clamp: 2;
-webkit-box-orient: vertical;
overflow: hidden;
text-overflow: ellipsis;
font-size: 1.05rem;
font-weight: 600;
line-height: 1.4;
color: var(--text-primary);
transition: color 0.2s ease;
}
.chapter-meta {
font-size: 0.85rem;
font-weight: 400;
color: var(--text-secondary);
}
.chapter-actions {
display: flex;
align-items: center;
justify-content: center;
padding: 0 16px;
}
.action-btn {
display: flex;
align-items: center;
justify-content: center;
width: 44px;
height: 44px;
border-radius: 50%;
background-color: transparent;
border: none;
color: var(--text-secondary);
font-size: 1.3rem;
cursor: pointer;
opacity: 0.7;
transition: background-color 0.2s ease, color 0.2s ease, transform 0.2s ease,
opacity 0.2s ease;
}
.chapter-item:hover .action-btn {
opacity: 1;
background-color: rgba(255, 255, 255, 0.1);
}
.action-btn:hover {
color: var(--brand-accent);
transform: scale(1.1);
}
.action-btn:active {
transform: scale(1);
}
.add-to-list-btn {
margin-right: 10px;
}
/* --- READER MODAL --- */
#reader-modal-container {
display: none;
position: fixed;
inset: 0;
z-index: 2000;
background-color: #121212;
opacity: 0;
transform: translateY(100%);
transition: transform 0.4s cubic-bezier(0.4, 0, 0.2, 1), opacity 0.4s ease;
}
#reader-modal-container.visible {
display: block;
transform: translateY(0);
opacity: 1;
}
#reader-modal-container iframe {
width: 100%;
height: 100%;
border: none;
}
/* --- UTILITIES & PLACEHOLDERS --- */
.loader {
width: 40px;
height: 40px;
margin: 2rem auto;
border: 4px solid var(--border-color);
border-top-color: var(--brand-accent);
border-radius: 50%;
animation: spin 1s linear infinite;
}
@keyframes spin {
to {
transform: rotate(360deg);
}
}
.placeholder-text {
padding: 2rem;
color: var(--text-secondary);
font-style: italic;
text-align: center;
}
.clear-cache-btn {
float: right;
padding: 10px 20px;
margin-bottom: 10px;
border-radius: 5px;
border: 4px solid var(--brand-accent);
background-color: rgba(0, 0, 0, 0.3);
box-shadow: inset 0 0 15px rgba(135, 135, 135, 0.1),
0 0 18px 3px rgba(0, 0, 0, 0.3);
color: white;
font-family: Tahoma, sans-serif;
font-weight: 600;
cursor: pointer;
}
.jump-read-btn {
padding: 10px 20px;
margin-bottom: 10px;
border-radius: 5px;
border: 4px solid var(--brand-accent);
background-color: rgba(0, 0, 0, 0.3);
box-shadow: inset 0 0 15px rgba(135, 135, 135, 0.1),
0 0 18px 3px rgba(0, 0, 0, 0.3);
color: white;
font-family: Tahoma, sans-serif;
font-weight: 600;
cursor: pointer;
}
/* --- RELATED & RECOMMENDATIONS --- */
.section-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 1.5rem;
}
.section-title {
margin: 0;
}
.content-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(180px, 1fr));
gap: 1.5rem;
}
.recommendation-card {
display: flex;
flex-direction: column;
border-radius: var(--border-radius);
background-color: var(--background-secondary);
box-shadow: 0 2px 8px var(--shadow-color);
color: var(--text-primary);
text-decoration: none;
overflow: hidden;
transition: transform var(--transition-duration) ease,
box-shadow var(--transition-duration) ease;
}
.recommendation-card:hover {
transform: translateY(-5px);
box-shadow: 0 8px 20px var(--shadow-color);
}
.recommendation-card-img-container {
width: 100%;
aspect-ratio: 2 / 3;
background-color: var(--background-tertiary);
overflow: hidden;
}
.recommendation-card-img {
width: 100%;
height: 100%;
object-fit: cover;
transition: transform var(--transition-duration) ease;
}
.recommendation-card:hover .recommendation-card-img {
transform: scale(1.1);
}
.recommendation-card-title {
flex-grow: 1;
padding: 0.75rem;
font-size: 0.9rem;
font-weight: 500;
line-height: 1.4;
}
.related-content-container {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(350px, 1fr));
gap: 1.5rem;
}
.related-card {
display: flex;
gap: 1rem;
margin-bottom: 1rem;
border-radius: var(--border-radius);
background-color: var(--background-secondary);
text-decoration: none;
overflow: hidden;
transition: background-color var(--transition-duration) ease,
transform var(--transition-duration) ease;
}
.related-card:hover {
background-color: var(--background-tertiary);
transform: translateY(-2px);
}
.related-card-img-container {
flex-shrink: 0;
width: 80px;
aspect-ratio: 2 / 3;
background-color: var(--background-tertiary);
overflow: hidden;
}
.related-card-img {
width: 100%;
height: 100%;
object-fit: cover;
}
.related-card-info {
display: flex;
flex-direction: column;
justify-content: center;
flex-grow: 1;
min-width: 0; /* Prevents flexbox overflow */
padding: 1rem 1rem 1rem 0;
}
.related-card-title {
margin-bottom: 0.5rem;
font-size: 1.1rem;
font-weight: 500;
color: var(--text-primary);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.related-card-tag {
align-self: flex-start;
padding: 4px 8px;
border-radius: 4px;
background-color: var(--brand-accent);
color: var(--background-primary);
font-size: 0.8rem;
font-weight: 600;
text-transform: capitalize;
}
.info-message {
padding: 2rem;
color: var(--text-muted);
font-style: italic;
text-align: center;
}
/* --- MANGADEX & META STYLES --- */
.mangadex-button {
display: inline-flex;
align-items: center;
gap: 8px;
padding: 8px 16px;
border: 1px solid var(--border-color);
border-radius: var(--border-radius);
background-color: #333;
color: white;
font-family: inherit;
font-size: 0.9rem;
font-weight: 600;
text-decoration: none;
cursor: pointer;
transition: background-color 0.2s ease, border-color 0.2s ease,
transform 0.2s ease;
}
.mangadex-button:hover {
background-color: #444;
border-color: var(--brand-accent);
transform: translateY(-2px);
}
.mangadex-button img {
height: 20px;
}
.chapter-meta-mangadex {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 8px;
margin-top: 6px;
}
.meta-tag {
display: inline-flex;
align-items: center;
gap: 6px;
padding: 4px 8px;
border: 1px solid var(--border-color);
border-radius: 4px;
background-color: rgba(255, 255, 255, 0.05);
color: var(--text-secondary);
font-size: 0.75rem;
font-weight: 600;
transition: background-color var(--transition-duration) ease,
border-color var(--transition-duration) ease;
}
.meta-tag .fas {
font-size: 0.8em;
}
.chapter-item:hover .meta-tag {
background-color: rgba(255, 255, 255, 0.1);
border-color: var(--brand-glow);
}
/* --- PAGINATION --- */
.pagination-container {
display: flex;
justify-content: center;
align-items: center;
gap: 0.5rem;
padding: 2rem 0;
}
.pagination-container button {
padding: 10px 18px;
border: 1px solid var(--border-color);
border-radius: var(--border-radius);
background-color: var(--background-secondary);
color: var(--text-primary);
font-weight: 600;
cursor: pointer;
transition: background-color var(--transition-duration) ease,
border-color var(--transition-duration) ease,
color var(--transition-duration) ease,
transform var(--transition-duration) ease;
}
.pagination-container button:hover:not(:disabled) {
background-color: var(--brand-accent);
border-color: var(--brand-accent);
color: var(--background-primary);
transform: translateY(-2px);
}
.pagination-container button:disabled {
opacity: 0.4;
cursor: not-allowed;
}
.pagination-container span {
padding: 0 1rem;
color: var(--text-secondary);
font-weight: 600;
}
/* --- RESPONSIVE ADJUSTMENTS --- */
@media (max-width: 768px) {
#hero-section {
height: 50vh;
min-height: 400px;
}
.vertical-title-jp {
display: none;
}
.cover-art-container {
height: 60%;
}
#content-sheet {
margin-top: -60px;
padding: 3rem 1.5rem 1.5rem;
}
.manga-title-en,
.manga-title-jp {
text-align: center;
}
.genres-container {
justify-content: center;
}
.tab-btn {
font-size: 1rem;
}
.chapter-item:hover {
transform: translateY(-2px) scale(1.01);
}
.chapter-thumbnail {
width: 80px;
}
.chapter-details {
gap: 2px;
padding: 8px 12px;
}
.chapter-title {
font-size: 0.9rem;
}
.chapter-meta {
font-size: 0.75rem;
}
.chapter-actions {
padding: 0 8px;
}
.action-btn.download-btn {
width: 36px;
height: 36px;
font-size: 1.1rem;
background-color: rgba(255, 255, 255, 0.05);
opacity: 1; /* Always visible on touch devices */
}
.action-btn.add-to-list-btn {
width: 36px;
height: 36px;
font-size: 1.1rem;
background-color: rgba(255, 255, 255, 0.05);
opacity: 1; /* Always visible on touch devices */
}
.related-content-container {
grid-template-columns: 1fr;
gap: 1rem;
}
}
@media (max-width: 480px) {
.chapter-meta-mangadex {
flex-direction: column;
align-items: flex-start;
gap: 8px;
}
.chapter-item .chapter-details {
padding: 12px 15px;
}
}
.add-list-btn-container {
display: flex;
justify-content: center;
margin-bottom: 1rem;
width: 100%;
}
.hero-action-btn {
padding: 6px 14px;
border: 1px solid var(--border-color);
border-radius: 4px;
background: transparent;
color: var(--text-secondary);
font-size: 0.9rem;
font-weight: 600;
transition: color var(--transition-duration) ease
}
.hero-action-btn:hover {
color: var(--brand-accent);
border-color: var(--brand-accent);
user-select: none;
}

View File

@@ -0,0 +1,27 @@
{
"name": "Animex",
"short_name": "Animex",
"description": "Animex - Anime and Manga Search and Player",
"start_url": "/?source=pwa",
"scope": "/",
"display": "standalone",
"background_color": "#0b0b0b",
"theme_color": "#FF9500",
"icons": [
{
"src": "/Resources/Images/logo-256.png",
"sizes": "400x400",
"type": "image/png"
},
{
"src": "/Resources/Images/logo-196.png",
"sizes": "196x196",
"type": "image/png"
},
{
"src": "/Resources/Images/logo-512.png",
"sizes": "512x512",
"type": "image/png"
}
]
}

View File

@@ -0,0 +1,186 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Animex</title>
<link rel="icon" href="Resources/favicon.png" type="image/png">
<link rel="stylesheet" href="Resources/styles.css">
</head>
<body>
<div id="app-wrapper">
<!-- Header Image -->
<img class="app-absolute header-main-image" style="left: -44px; top: -26px;" src="https://images.unsplash.com/photo-1579803815617-16568f90450e?q=80&w=1933&auto=format&fit=crop" alt="Header Background"/>
<!-- Featured Content -->
<img id="featured-poster" class="app-absolute featured-poster-img" style="left: 36px; top: 59px;" src="https://placehold.co/126x193/99AAB5/FFFFFF?text=Featured&font=montserrat" alt="Featured Poster"/>
<div id="featured-info" class="app-absolute flex-center-col featured-small-text" style="left: 176px; top: 82px;">TYPE | YEAR</div>
<div id="featured-title" class="app-absolute featured-title-text" style="left: 176px; top: 105px; width: 184px; height: auto; max-height: 80px;">Featured Manga Title</div>
<div class="app-absolute featured-button-background" style="left: 176px; top: 203px;"></div>
<div class="app-absolute flex-center-col featured-small-text" style="left: 205px; top: 207px;">READ MORE</div>
<div class="app-absolute featured-button-icon-svg-wrapper" style="left: 185px; top: 207px;">
<svg width="13" height="13" viewBox="0 0 13 13" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M2.15329 12.2561C2.28704 12.2561 2.38735 12.2214 2.55453 12.1171L10.9203 6.98663C11.1543 6.85455 11.2948 6.72246 11.2948 6.5C11.2948 6.28449 11.1543 6.15241 10.9203 6.01337L2.55453 0.882887C2.38735 0.778609 2.28704 0.74385 2.15329 0.74385C1.8858 0.74385 1.70525 0.945454 1.70525 1.29305V11.707C1.70525 12.0545 1.8858 12.2561 2.15329 12.2561Z" fill="white" stroke="white"/>
</svg>
</div>
<!-- Explore Tab Backing & Content -->
<div class="explore-backing">
<div class="explore-title">
Explore Manga
</div>
<div id="scrollable-list-content">
<!-- Manga items will be injected here by JavaScript -->
<div class="loading-message">Loading manga...</div>
</div>
</div>
<!-- Bottom Navigation Bar -->
<div class="bottom-tabbar">
<!-- MANGA Tab (Active) -->
<div class="tab-item">
<div class="app-absolute tab-icon-svg-wrapper" style="left: 24px; top: 12px;">
<svg width="33" height="29" viewBox="0 0 33 29" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M10.1507 24.6181C13.4752 24.6181 15.737 26.2917 16.0776 27.0829C16.0912 27.1286 16.1185 27.159 16.1594 27.1894V3.74294C15.3146 2.93654 12.9983 1.73455 10.1643 1.73455C6.68992 1.73455 3.46078 3.71251 3.4744 4.79279V26.8547C3.4744 27.0981 3.62428 27.2655 3.84228 27.2655C4.04665 27.2655 4.19653 27.1894 4.25103 27.0829C4.59166 26.307 6.8398 24.6181 10.1507 24.6181ZM22.8493 24.6181C26.1738 24.6181 28.422 26.307 28.7626 27.0829C28.8035 27.1894 28.9533 27.2655 29.1577 27.2655C29.3757 27.2655 29.5256 27.0981 29.5256 26.8547V4.79279C29.5392 3.71251 26.3237 1.73455 22.8357 1.73455C20.0017 1.73455 17.699 2.93654 16.8406 3.74294V27.1894C16.8951 27.159 16.9088 27.1286 16.9224 27.0829C17.263 26.2917 19.5248 24.6181 22.8493 24.6181Z" fill="#B6521B" stroke="#B6521B"/>
</svg>
</div>
<div class="app-absolute tab-label-text" style="left: 17px; color: #B6521B;">MANGA</div>
</div>
<!-- ANIME Tab -->
<div class="tab-item">
<div class="app-absolute tab-icon-svg-wrapper" style="left: 104px; top: 12px;">
<svg width="35" height="29" viewBox="0 0 35 29" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M4.73459 26.8169H30.2654C31.3442 26.8169 31.9435 26.1127 31.9435 24.8451V4.15492C31.9435 2.9014 31.3322 2.18309 30.2654 2.18309H4.73459C3.66781 2.18309 3.05651 2.9014 3.05651 4.15492V24.8451C3.05651 26.1127 3.65582 26.8169 4.73459 26.8169ZM4.73459 26.1268C3.97945 26.1268 3.64384 25.7324 3.64384 24.8451V4.15492C3.64384 3.28168 3.99144 2.87323 4.73459 2.87323H30.2654C31.0086 2.87323 31.3562 3.28168 31.3562 4.15492V24.8451C31.3562 25.7324 31.0325 26.1268 30.2654 26.1268H4.73459ZM4.95034 6.25351C4.95034 6.59154 5.19007 6.88732 5.53767 6.88732H6.77226C7.11986 6.88732 7.33562 6.59154 7.33562 6.25351V4.92957C7.33562 4.52112 7.11986 4.25351 6.77226 4.25351H5.53767C5.1661 4.25351 4.95034 4.52112 4.95034 4.92957V6.25351ZM27.6764 6.25351C27.6764 6.59154 27.8921 6.88732 28.2397 6.88732H29.4863C29.8219 6.88732 30.0497 6.59154 30.0497 6.25351V4.92957C30.0497 4.52112 29.8219 4.25351 29.4863 4.25351H28.2397C27.8681 4.25351 27.6764 4.52112 27.6764 4.92957V6.25351ZM8.72603 12.5352C8.72603 13 8.97774 13.2676 9.37329 13.2676H25.6267C26.0342 13.2676 26.274 13 26.274 12.5352V5.0845C26.274 4.60563 26.0342 4.33802 25.6267 4.33802H9.37329C8.97774 4.33802 8.72603 4.60563 8.72603 5.0845V12.5352ZM4.95034 10.7042C4.95034 11.0563 5.19007 11.3521 5.53767 11.3521H6.77226C7.11986 11.3521 7.33562 11.0563 7.33562 10.7042V9.38027C7.33562 8.97182 7.11986 8.7183 6.77226 8.7183H5.53767C5.1661 8.7183 4.95034 8.97182 4.95034 9.38027V10.7042ZM27.6764 10.7042C27.6764 11.0563 27.8921 11.3521 28.2397 11.3521H29.4863C29.8219 11.3521 30.0497 11.0563 30.0497 10.7042V9.38027C30.0497 8.97182 29.8219 8.7183 29.4863 8.7183H28.2397C27.8681 8.7183 27.6764 8.97182 27.6764 9.38027V10.7042ZM4.95034 15.169C4.95034 15.507 5.19007 15.7887 5.53767 15.7887H6.77226C7.11986 15.7887 7.33562 15.507 7.33562 15.169V13.8451C7.33562 13.4366 7.11986 13.169 6.77226 13.169H5.53767C5.1661 13.169 4.95034 13.4366 4.95034 13.8451V15.169ZM27.6764 15.169C27.6764 15.507 27.8921 15.7887 28.2397 15.7887H29.4863C29.8219 15.7887 30.0497 15.507 30.0497 15.169V13.8451C30.0497 13.4366 29.8219 13.169 29.4863 13.169H28.2397C27.8681 13.169 27.6764 13.4366 27.6764 13.8451V15.169ZM8.72603 23.8873C8.72603 24.3521 8.97774 24.6197 9.37329 24.6197H25.6267C26.0342 24.6197 26.274 24.3521 26.274 23.8873V16.4225C26.274 15.9577 26.0342 15.6901 25.6267 15.6901H9.37329C8.97774 15.6901 8.72603 15.9577 8.72603 16.4225V23.8873ZM4.95034 19.6197C4.95034 19.9577 5.19007 20.2394 5.53767 20.2394H6.77226C7.11986 20.2394 7.33562 19.9577 7.33562 19.6197V18.2958C7.33562 17.8873 7.11986 17.6197 6.77226 17.6197H5.53767C5.1661 17.6197 4.95034 17.8873 4.95034 18.2958V19.6197ZM27.6764 19.6197C27.6764 19.9577 27.8921 20.2394 28.2397 20.2394H29.4863C29.8219 20.2394 30.0497 19.9577 30.0497 19.6197V18.2958C30.0497 17.8873 29.8219 17.6197 29.4863 17.6197H28.2397C27.8681 17.6197 27.6764 17.8873 27.6764 18.2958V19.6197ZM4.95034 24.0563C4.95034 24.4225 5.19007 24.7042 5.53767 24.7042H6.77226C7.11986 24.7042 7.33562 24.4225 7.33562 24.0563V22.7324C7.33562 22.3521 7.11986 22.0845 6.77226 22.0845H5.53767C5.1661 22.0845 4.95034 22.3521 4.95034 22.7324V24.0563ZM27.6764 24.0563C27.6764 24.4225 27.8921 24.7042 28.2397 24.7042H29.4863C29.8219 24.7042 30.0497 24.4225 30.0497 24.0563V22.7324C30.0497 22.3521 29.8219 22.0845 29.4863 22.0845H28.2397C27.8681 22.0845 27.6764 22.3521 27.6764 22.7324V24.0563Z" fill="#5B574C" stroke="#5B574C"/>
</svg>
</div>
<div class="app-absolute tab-label-text" style="left: 101px; color: #5B574C;">ANIME</div>
</div>
<!-- SEARCH Tab -->
<div class="tab-item">
<div class="app-absolute tab-icon-svg-wrapper" style="left: 183px; top: 10px;">
<svg width="35" height="37" viewBox="0 0 35 37" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M17.5 33.2075C25.625 33.2075 32.2713 26.5933 32.2713 18.4924C32.2713 10.4067 25.625 3.79253 17.5 3.79253C9.375 3.79253 2.72866 10.4067 2.72866 18.4924C2.72866 26.5933 9.375 33.2075 17.5 33.2075ZM17.5 32.4641C9.77134 32.4641 3.47561 26.1988 3.47561 18.4924C3.47561 10.8011 9.77134 4.53587 17.5 4.53587C25.2439 4.53587 31.5244 10.8011 31.5244 18.4924C31.5244 26.1988 25.2439 32.4641 17.5 32.4641ZM15.8079 22.2243C17.1341 22.2243 18.3537 21.7692 19.314 20.9803L23.75 25.41C23.9024 25.5617 24.0091 25.6376 24.2073 25.6376C24.5122 25.6376 24.6951 25.4252 24.6951 25.1673C24.6951 24.9701 24.6189 24.879 24.4817 24.7425L20.0305 20.2977C20.8689 19.3268 21.3872 18.0525 21.3872 16.672C21.3872 13.6228 18.872 11.1045 15.8079 11.1045C12.7287 11.1045 10.2134 13.6228 10.2134 16.672C10.2134 19.7364 12.7287 22.2243 15.8079 22.2243ZM15.8079 21.4658C13.1555 21.4658 10.9756 19.3116 10.9756 16.672C10.9756 14.0324 13.1555 11.8631 15.8079 11.8631C18.4604 11.8631 20.625 14.0324 20.625 16.672C20.625 19.3116 18.4604 21.4658 15.8079 21.4658Z" fill="#5B574C" stroke="#5B574C"/>
</svg>
</div>
<div class="app-absolute tab-label-text" style="left: 175px; color: #5B574C;">SEARCH</div>
</div>
<!-- LIBRARY Tab -->
<div class="tab-item">
<div class="app-absolute tab-icon-svg-wrapper" style="left: 269px; top: 8px;">
<svg width="24" height="39" viewBox="0 0 24 39" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M3.82036 34.5045C4.10779 34.5045 4.34731 34.344 4.58683 34.052L11.2814 25.893C11.5928 25.5135 11.7844 25.4405 12 25.4405C12.2156 25.4405 12.4192 25.5135 12.7186 25.893L19.4012 34.0375C19.6647 34.3732 19.8922 34.5045 20.1796 34.5045C20.6467 34.5045 20.9461 34.0958 20.9461 33.4828V8.24666C20.9461 5.80916 19.8802 4.49554 17.8683 4.49554H6.13174C4.11976 4.49554 3.05389 5.80916 3.05389 8.24666V33.4828C3.05389 34.0958 3.3533 34.5045 3.82036 34.5045Z" fill="#5B574C" stroke="#5B574C"/>
</svg>
</div>
<div class="app-absolute tab-label-text" style="left: 255px; color: #5B574C;">LIBRARY</div>
</div>
<!-- SETTINGS Tab -->
<div class="tab-item">
<div class="app-absolute tab-icon-svg-wrapper" style="left: 347.28px; top: 12px;">
<svg width="30" height="30" viewBox="0 0 30 30" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M29.1824 12.5815L25.4 11.9632C25.1786 11.2042 24.8758 10.4746 24.497 9.78403L26.705 6.63715C26.8835 6.38307 26.8528 6.03683 26.6333 5.81667L24.1443 3.32899C23.9222 3.10755 23.5728 3.07875 23.3181 3.26243L20.2186 5.49027C19.5216 5.10499 18.7856 4.79907 18.0221 4.57699L17.3622 0.809949C17.3085 0.504029 17.0429 0.280029 16.7318 0.280029H13.2118C12.8982 0.280029 12.6307 0.507229 12.5802 0.816989L11.9683 4.56355C11.2003 4.78435 10.463 5.08707 9.76864 5.46723L6.67744 3.25923C6.42208 3.07683 6.07456 3.10627 5.85248 3.32707L3.3648 5.81475C3.14528 6.03427 3.11456 6.37987 3.29312 6.63395L5.46848 9.74627C5.08128 10.4471 4.7728 11.1888 4.54816 11.9607L0.81632 12.5821C0.50784 12.6333 0.281281 12.9008 0.281281 13.2131V16.7331C0.281281 17.0435 0.504 17.3091 0.80928 17.3636L4.54112 18.0253C4.76448 18.7952 5.07296 19.537 5.46144 20.2397L3.2592 23.32C3.07744 23.5741 3.10624 23.9229 3.32704 24.145L5.81536 26.6352C6.03488 26.8547 6.38112 26.8855 6.6352 26.7069L9.752 24.5239C10.4515 24.9085 11.1907 25.2138 11.9568 25.4352L12.5814 29.1863C12.632 29.4941 12.8989 29.72 13.2118 29.72H16.7318C17.0422 29.72 17.3078 29.4973 17.3616 29.192L18.0304 25.4224C18.7978 25.1965 19.5331 24.8893 20.2256 24.504L23.3648 26.7063C23.6195 26.8861 23.9651 26.8547 24.1853 26.6352L26.6736 24.145C26.895 23.9229 26.9238 23.5728 26.7402 23.3181L24.5014 20.2096C24.881 19.5184 25.1824 18.7875 25.4019 18.0285L29.1894 17.3636C29.496 17.3098 29.7187 17.0435 29.7187 16.7331V13.2131C29.7194 12.8995 29.4922 12.632 29.1824 12.5815ZM15 19.48C12.5258 19.48 10.52 17.4743 10.52 15C10.52 12.5258 12.5258 10.52 15 10.52C17.4742 10.52 19.48 12.5258 19.48 15C19.48 17.4743 17.4742 19.48 15 19.48Z" fill="#5B574C"/>
</svg>
</div>
<div class="app-absolute tab-label-text" style="left: 331px; color: #5B574C;">SETTINGS</div>
</div>
</div>
</div>
<div id="desktop-message">
Desktop app isn't available now. Please use a mobile device or resize your browser to a mobile width (e.g., less than 420px wide).
</div>
<script>
document.addEventListener('DOMContentLoaded', () => {
const PROXY_URL = 'https://cors-anywhere.herokuapp.com/'; // Example public proxy
const API_URL = PROXY_URL + 'https://api.consumet.org/meta/anilist/trending?type=MANGA&perPage=15'; // Example: Fetch 15 trending manga
const scrollableListContent = document.getElementById('scrollable-list-content');
// Featured content elements
const featuredPoster = document.getElementById('featured-poster');
const featuredInfo = document.getElementById('featured-info');
const featuredTitle = document.getElementById('featured-title');
async function fetchManga() {
try {
const response = await fetch(API_URL);
if (!response.ok) {
throw new Error(`HTTP error! status: ${response.status}`);
}
const data = await response.json();
displayManga(data.results);
if (data.results && data.results.length > 0) {
displayFeaturedManga(data.results[0]);
}
} catch (error) {
scrollableListContent.innerHTML = `<div class="error-message">Failed to load manga. ${error.message}</div>`;
console.error('Error fetching manga:', error);
}
}
function displayManga(mangaList) {
scrollableListContent.innerHTML = ''; // Clear loading message or previous content
if (!mangaList || mangaList.length === 0) {
scrollableListContent.innerHTML = '<div class="info-message">No manga found.</div>';
return;
}
mangaList.forEach(manga => {
const title = manga.title.english || manga.title.romaji || 'N/A';
const coverImage = manga.image || 'https://placehold.co/58x64/333333/FFFFFF?text=N/A&font=montserrat';
const rating = manga.rating ? (manga.rating / 10).toFixed(1) : 'N/A';
const chapters = manga.totalChapters || manga.chapters || 'N/A';
const status = manga.status || 'N/A';
const year = manga.releaseDate || 'N/A';
const listItemWrapper = document.createElement('div');
listItemWrapper.className = 'list-item-wrapper';
// Adjust inline styles as needed or move more to CSS if positions become complex
listItemWrapper.innerHTML = `
<div class="app-absolute li-thumb-container" style="top: 5px; left: 10px;">
<img class="li-thumb-img" src="${coverImage}" alt="${title} cover" />
</div>
<div class="app-absolute flex-center-col li-main-title-text" style="left: 85px; top: 8px; width: calc(100% - 125px);">${title}</div>
<div class="app-absolute li-rating-badge-bg" style="left: 85px; top: 48px;"></div>
<div class="app-absolute flex-center-col li-badge-text li-rating-text-spacing" style="left: 88px; top: 51px; width: 80px;">${rating}/10</div>
<div class="app-absolute li-chapters-badge-bg" style="left: 180px; top: 48px;"></div>
<div class="app-absolute flex-center-col li-badge-text" style="left: 183px; top: 51px; width: auto; padding: 0 5px;">${chapters} Ch.</div>
<div class="app-absolute li-arrow-icon-wrapper" style="right: 15px; top: 24px;">
<img src="Resources/Svgs/item-list-arrow.svg" alt="Arrow icon" width="23" height="32"/>
</div>
`;
// Note: If 'Resources/Svgs/item-list-arrow.svg' is not available, replace or remove this arrow.
// For simplicity, I'm keeping the arrow SVG as an img, assuming it's a UI element not a background.
scrollableListContent.appendChild(listItemWrapper);
});
}
function displayFeaturedManga(manga) {
if (!manga) return;
const title = manga.title.english || manga.title.romaji || 'N/A';
const coverImage = manga.image || 'https://placehold.co/126x193/333333/FFFFFF?text=N/A&font=montserrat';
const type = manga.type || 'MANGA';
const year = manga.releaseDate || 'N/A';
featuredPoster.src = coverImage;
featuredPoster.alt = `${title} Poster`;
featuredInfo.textContent = `${type} | ${year}`;
featuredTitle.textContent = title;
}
fetchManga();
});
</script>
</body>
</html>

View File

@@ -0,0 +1,270 @@
/* body, #app-wrapper, #desktop-message, @media (max-width: 420px) rules remain the same as your previous version */
body {
margin: 0;
background-color: #e0e0e0; /* A light grey for desktop background */
display: flex;
justify-content: center;
align-items: center;
min-height: 100vh;
font-family: Verdana, sans-serif;
}
#app-wrapper {
width: 402px; /* Design reference width */
height: 874px; /* Design reference height */
position: relative;
background: white;
overflow: hidden; /* Crucial to contain absolutely positioned children */
display: none; /* Hidden by default, shown via media query for mobile */
box-shadow: 0 0 20px rgba(0,0,0,0.2); /* Optional: for better visual separation if shown on desktop */
}
/* --- Desktop Message --- */
#desktop-message {
display: flex; /* Shown by default on desktop/larger screens */
justify-content: center;
align-items: center;
width: 100%;
height: 100vh; /* Can be min-height: 100vh as well */
text-align: center;
font-size: 20px;
color: #333;
padding: 20px;
box-sizing: border-box;
}
/* --- Styles for elements INSIDE #app-wrapper --- */
/* --- Explore Tab Backing --- */
.explore-backing {
position: absolute;
left: 0;
top: 299px;
width: 100%;
background: #36362F;
border-top-left-radius: 25px;
border-top-right-radius: 25px;
box-sizing: border-box;
}
/* --- Explore Title --- */
.explore-title {
position: absolute;
left: 19px;
top: 15px;
color: white;
font-size: 24px; /* Slightly reduced for potentially longer text */
font-family: Verdana;
font-weight: 700;
word-wrap: break-word;
z-index: 1;
}
/* --- Scrollable List --- */
#scrollable-list-content {
position: absolute;
top: 60px;
left: 0;
width: 100%;
overflow-y: auto;
padding: 0 10px 10px 10px;
box-sizing: border-box;
}
.loading-message, .error-message, .info-message {
color: white;
text-align: center;
padding: 20px;
font-size: 16px;
}
/* --- List Items --- */
.list-item-wrapper {
position: relative;
width: 100%;
height: 80px; /* Increased height slightly for better spacing */
margin-bottom: 12px;
box-sizing: border-box;
background-color: rgba(255, 255, 255, 0.05); /* Subtle background for items */
border-radius: 8px;
}
.list-item-wrapper:last-child {
margin-bottom: 0;
}
/* --- Helper Classes --- */
.app-absolute {
position: absolute;
}
.flex-center-col {
justify-content: center; /* Vertically center text in its given height */
align-items: flex-start; /* Align text to the start horizontally */
display: flex;
flex-direction: column;
}
/* --- Image Adjustments --- */
img {
object-fit: cover;
display: block;
}
/* --- Bottom Navigation Bar --- */
.bottom-tabbar {
position: absolute;
left: 0;
right: 0;
bottom: 0;
width: 100%;
height: 98px;
background: #22221C;
box-sizing: border-box;
z-index: 10;
}
/* --- Header and Featured Content Specific Styles --- */
.header-main-image {
width: 540px;
height: 409px;
box-shadow: 1.6px 1.6px 1.6px rgba(0,0,0,0.1);
filter: blur(1.5px) brightness(0.7); /* Adjusted for better text visibility */
}
.featured-poster-img {
width: 126px;
height: 193px;
box-shadow: 6px 6px 15px rgba(0, 0, 0, 0.3);
border-radius: 8px;
}
.featured-small-text {
color: white;
font-size: 10px;
font-family: Verdana;
font-weight: 600;
word-wrap: break-word;
}
.featured-title-text { /* New class for the featured title div */
color: white;
font-size: 20px;
font-family: Verdana;
font-weight: 700;
line-height: 1.2;
word-wrap: break-word;
overflow: hidden;
text-overflow: ellipsis;
display: -webkit-box;
-webkit-line-clamp: 3; /* Limit to 3 lines */
-webkit-box-orient: vertical;
}
.featured-button-background {
width: 160px;
height: 21px;
background: rgba(54, 54, 47, 0.8); /* Darker, slightly transparent */
border-radius: 81px;
}
.featured-button-icon-svg-wrapper {
/* Positioned by inline styles */
}
/* List Item Specific Styles (Updated for CSS backgrounds) */
.li-thumb-container {
/* No explicit background needed if img fills it */
width: 58px; /* Ensure container size matches image */
height: 64px;
/* left: 10px; top: 5px; (from JS) */
}
.li-thumb-img {
width:58px;
height:64px;
border-radius:9px; /* Rounded corners for the manga cover */
}
.li-main-title-text {
/* top: 8px; left: 85px; (from JS) */
color: white;
font-size: 16px; /* Adjusted for better fit */
font-family: Verdana;
font-weight: 700;
line-height: 1.2;
max-height: 38px; /* Approx 2 lines */
overflow: hidden;
text-overflow: ellipsis;
/* width: calc(100% - 125px); (from JS - to prevent overlap with arrow) */
display: -webkit-box; /* For multi-line ellipsis */
-webkit-line-clamp: 2;
-webkit-box-orient: vertical;
}
.li-rating-badge-bg, .li-chapters-badge-bg { /* Combined for similar styling */
height: 22px; /* Adjusted height */
background: #22221C; /* Dark background for badges */
border-radius: 11px; /* Rounded corners */
/* top: 48px; (from JS) */
}
.li-rating-badge-bg {
width: 85px; /* Fixed width for rating */
/* left: 85px; (from JS) */
}
.li-chapters-badge-bg {
min-width: 55px; /* Min width, can grow */
padding: 0 8px; /* Padding for dynamic width */
/* left: 180px; (from JS) */
}
.li-badge-text {
/* height: 19px; (Not strictly needed with flex centering) */
/* top: 51px; (from JS, vertically align with badge bg) */
text-align: center;
color: white;
font-size: 11px; /* Adjusted for badge size */
font-family: Verdana;
font-weight: 700;
line-height: 22px; /* Match badge height for vertical center */
white-space: nowrap;
/* width and left are set in JS or specific badge bg above */
}
.li-rating-text-spacing {
letter-spacing: 0.5px;
}
.li-arrow-icon-wrapper {
/* top: 24px; right: 15px; (from JS) */
}
/* Bottom Tab Bar Specific Styles */
.tab-icon-svg-wrapper {
/* Positioned via inline styles (top, left) */
}
.tab-label-text {
top: 66px;
text-align: center;
font-size: 12px;
font-family: Verdana;
font-weight: 700;
word-wrap: break-word;
}
/* --- Mobile View Overrides & Enhancements --- */
@media (max-width: 420px) {
body {
background-color: white;
}
#app-wrapper {
display: block;
width: 100vw;
height: 100vh;
margin: 0;
box-shadow: none;
}
#desktop-message {
display: none;
}
.explore-backing {
bottom: 98px;
}
#scrollable-list-content {
height: calc(100% - 60px);
}
}

540
animex/Resources/series.css Normal file
View File

@@ -0,0 +1,540 @@
/* =========================================
1. VARIABLES & RESET
========================================= */
:root {
/* Brand & Palette */
--brand-accent: #ff9500;
--brand-accent-hover: #ffae40;
--background-primary: #0a0a0a;
--background-secondary: #161616;
--background-tertiary: #202020;
/* Text Colors */
--text-primary: #eaeaea;
--text-secondary: #999999;
--text-muted: #666666;
/* UI Elements */
--border-color: #2a2a2a;
--shadow-color: rgba(0, 0, 0, 0.6);
--brand-glow: rgba(255, 149, 0, 0.5);
/* Dimensions & Animation */
--transition-duration: 0.3s;
--border-radius: 8px;
--nav-height: 60px;
}
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
html {
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
scroll-behavior: smooth;
}
body {
background-color: var(--background-primary);
color: var(--text-primary);
font-family: 'Inter', -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif;
line-height: 1.6;
overflow-x: hidden;
}
/* Custom Scrollbar */
::-webkit-scrollbar {
width: 8px;
height: 8px;
}
::-webkit-scrollbar-track {
background: var(--background-secondary);
}
::-webkit-scrollbar-thumb {
background: #444;
border-radius: 4px;
}
::-webkit-scrollbar-thumb:hover {
background: #555;
}
/* =========================================
2. APP STRUCTURE & HERO SECTION
========================================= */
.app-container {
width: 100%;
min-height: 100vh;
display: flex;
flex-direction: column;
}
/* Hero Section */
.series-hero-section {
position: relative;
height: 70vh;
min-height: 550px;
background-size: cover;
background-position: center 20%;
display: flex;
flex-direction: column;
justify-content: flex-end;
padding: 0 5% 4rem;
transition: background-image var(--transition-duration) ease-in-out;
}
.hero-overlay {
position: absolute;
inset: 0;
background: linear-gradient(
to top,
var(--background-primary) 15%,
rgba(10, 10, 10, 0.9) 50%,
rgba(10, 10, 10, 0.4) 80%,
rgba(0,0,0,0.3) 100%
);
z-index: 1;
pointer-events: none;
}
.series-hero-content {
position: relative;
z-index: 2;
width: 100%;
max-width: 1600px;
margin: 0 auto;
display: flex;
align-items: flex-end;
gap: 3rem;
animation: fadeInUp 0.6s cubic-bezier(0.19, 1, 0.22, 1);
}
/* Desktop Poster in Hero */
.hero-poster-container {
display: none; /* Hidden on mobile */
width: 280px;
flex-shrink: 0;
border-radius: 8px;
overflow: hidden;
box-shadow: 0 10px 40px rgba(0,0,0,0.5);
border: 1px solid rgba(255,255,255,0.1);
}
.hero-poster-img {
width: 100%;
height: auto;
display: block;
}
.hero-text-content {
flex: 1;
}
@keyframes fadeInUp {
from { opacity: 0; transform: translateY(30px); }
to { opacity: 1; transform: translateY(0); }
}
.series-title-text {
font-size: clamp(2rem, 5vw, 3.8rem);
font-weight: 800;
color: var(--text-primary);
text-shadow: 2px 2px 15px rgba(0, 0, 0, 0.9);
line-height: 1.1;
margin-bottom: 0.5rem;
letter-spacing: -0.02em;
}
/* Hero Sub-metadata (Rating, Studio) */
.hero-sub-meta {
display: flex;
align-items: center;
gap: 15px;
margin-bottom: 1.5rem;
font-size: 0.95rem;
color: #ccc;
font-weight: 500;
}
.hero-rating-badge {
background: rgba(255,255,255,0.1);
padding: 2px 8px;
border-radius: 4px;
display: flex;
align-items: center;
gap: 5px;
border: 1px solid rgba(255,255,255,0.2);
}
.hero-rating-badge i { color: var(--brand-accent); font-size: 0.8em; }
/* --- HERO ACTIONS --- */
.hero-actions {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 12px;
margin-top: 1.5rem;
}
/* Primary Watch Button */
.hero-watch-btn {
display: inline-flex;
align-items: center;
justify-content: center;
gap: 10px;
background-color: var(--brand-accent);
color: #000;
padding: 12px 28px;
border-radius: 4px;
border: none;
font-size: 1rem;
font-weight: 700;
cursor: pointer;
box-shadow: 0 4px 15px rgba(255, 149, 0, 0.3);
transition: all 0.2s ease;
text-transform: uppercase;
letter-spacing: 0.5px;
}
.hero-watch-btn:hover {
background-color: var(--brand-accent-hover);
transform: translateY(-2px) scale(1.02);
box-shadow: 0 6px 25px rgba(255, 149, 0, 0.5);
}
/* Secondary Action Buttons */
.hero-action-btn {
display: inline-flex;
align-items: center;
justify-content: center;
gap: 8px;
background: rgba(255, 255, 255, 0.1);
border: 1px solid rgba(255, 255, 255, 0.2);
color: var(--text-primary);
padding: 0 20px;
height: 48px;
border-radius: 4px;
font-size: 0.95rem;
font-weight: 600;
cursor: pointer;
backdrop-filter: blur(5px);
transition: all 0.2s ease;
}
.hero-action-btn:hover {
background-color: rgba(255, 255, 255, 0.2);
border-color: var(--text-primary);
}
.back-btn {
position: absolute;
top: 25px;
left: 25px;
z-index: 10;
background: rgba(0, 0, 0, 0.5);
border: none;
color: var(--text-primary);
width: 44px;
height: 44px;
border-radius: 50%;
font-size: 1.2rem;
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
transition: all 0.2s ease;
}
.back-btn:hover { background-color: rgba(255,255,255,0.2); }
/* =========================================
3. MAIN CONTENT
========================================= */
.main-content {
position: relative;
padding: 0 5% 4rem;
background-color: var(--background-primary);
z-index: 3;
max-width: 1600px;
margin: 0 auto;
}
.series-details-section {
margin-bottom: 3rem;
border-bottom: 1px solid var(--border-color);
padding-bottom: 2rem;
}
.series-meta-info {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 8px 16px;
margin-bottom: 1.5rem;
font-size: 0.9rem;
color: var(--text-secondary);
}
.series-meta-info span:not(:last-child)::after {
content: "•";
margin-left: 16px;
color: var(--border-color);
}
#series-score { color: var(--brand-accent); font-weight: 700; }
.details-grid {
display: grid;
grid-template-columns: 2fr 1fr;
gap: 3rem;
}
#series-synopsis {
font-size: 1rem;
line-height: 1.7;
color: #ccc;
}
#series-info-additional p {
margin-bottom: 0.5rem;
color: var(--text-muted);
font-size: 0.9rem;
}
#series-info-additional strong { color: #fff; }
/* Genres/Studios */
.series-genres span, .series-studios span {
display: inline-block;
margin: 5px 5px 0 0;
padding: 4px 10px;
background: var(--background-secondary);
border: 1px solid var(--border-color);
border-radius: 4px;
font-size: 0.8rem;
color: #aaa;
}
/* =========================================
4. TABS & SECTIONS LAYOUT
========================================= */
.tabs-section { margin-top: 2rem; }
/* Desktop: Hide Tabs, Stack Panels */
@media (min-width: 769px) {
.tabs-container { display: none; }
.tab-panel {
display: block !important;
margin-bottom: 4rem;
opacity: 1 !important;
}
/* Section Headings for Desktop Stacking */
.tab-panel::before {
content: attr(data-label);
display: block;
font-size: 1.5rem;
font-weight: 700;
margin-bottom: 1.5rem;
color: var(--text-primary);
border-left: 4px solid var(--brand-accent);
padding-left: 12px;
}
}
/* Mobile: Show Tabs */
@media (max-width: 768px) {
.tabs-container {
display: flex;
overflow-x: auto;
gap: 1.5rem;
border-bottom: 1px solid var(--border-color);
margin-bottom: 1.5rem;
padding-bottom: 0.5rem;
}
.tab-btn {
background: none;
border: none;
color: var(--text-secondary);
font-size: 1rem;
font-weight: 600;
white-space: nowrap;
padding: 0.5rem 0;
cursor: pointer;
position: relative;
}
.tab-btn.active { color: var(--text-primary); }
.tab-btn.active::after {
content: '';
position: absolute;
bottom: -0.5rem;
left: 0; right: 0;
height: 2px;
background: var(--brand-accent);
}
.tab-panel { display: none; }
.tab-panel.active { display: block; animation: fadeIn 0.3s; }
}
/* =========================================
5. EPISODES
========================================= */
.section-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 1.5rem;
gap: 1rem;
}
.season-selector {
padding: 8px 12px;
border-radius: 4px;
background: var(--background-secondary);
color: var(--text-primary);
border: 1px solid var(--border-color);
outline: none;
font-weight: 600;
}
.episode-list { display: flex; flex-direction: column; gap: 8px; }
.episode-item {
display: flex;
align-items: center;
background: var(--background-secondary);
border-radius: 4px;
overflow: hidden;
cursor: pointer;
transition: background 0.2s;
height: 90px;
padding-right: 15px;
}
.episode-item:hover { background: var(--background-tertiary); }
.episode-item.watched .episode-title { color: var(--text-muted); }
.episode-item.watched { border-left: 3px solid var(--brand-accent); }
.episode-thumbnail {
width: 150px;
height: 100%;
position: relative;
margin-right: 15px;
flex-shrink: 0;
}
.episode-thumbnail img { width: 100%; height: 100%; object-fit: cover; }
.episode-info { flex: 1; min-width: 0; }
.episode-title { font-weight: 600; font-size: 1rem; margin-bottom: 4px; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.episode-title-romanji { font-size: 0.85rem; color: var(--text-secondary); }
.episode-action-icon {
background: none; border: none; color: var(--text-secondary);
width: 36px; height: 36px; border-radius: 50%;
cursor: pointer; margin-left: 5px;
display: flex; align-items: center; justify-content: center;
}
.episode-action-icon:hover { background: rgba(255,255,255,0.1); color: var(--brand-accent); }
/* =========================================
6. HORIZONTAL SCROLL LISTS (Chars, Rel, Rec)
========================================= */
.horizontal-scroll-container {
display: flex;
gap: 15px;
overflow-x: auto;
padding-bottom: 15px;
scroll-snap-type: x mandatory;
}
/* Character Card */
.character-card {
flex: 0 0 280px;
background: var(--background-secondary);
border-radius: 6px;
display: flex;
overflow: hidden;
scroll-snap-align: start;
border: 1px solid var(--border-color);
}
.char-image-box, .va-image-box {
width: 70px;
height: 100px;
flex-shrink: 0;
}
.char-image-box img, .va-image-box img {
width: 100%; height: 100%; object-fit: cover;
}
.char-info {
flex: 1;
padding: 10px;
display: flex;
flex-direction: column;
justify-content: space-between;
font-size: 0.85rem;
}
.char-name { font-weight: 700; color: var(--text-primary); line-height: 1.2; }
.char-role { font-size: 0.75rem; color: var(--text-muted); }
.va-name { text-align: right; font-size: 0.8rem; color: var(--text-primary); }
.va-lang { text-align: right; font-size: 0.7rem; color: var(--text-muted); }
/* Language Tabs for Characters */
.char-lang-tabs {
display: flex;
gap: 10px;
margin-bottom: 15px;
}
.lang-tab {
background: var(--background-secondary);
border: 1px solid var(--border-color);
color: var(--text-secondary);
padding: 4px 12px;
border-radius: 20px;
font-size: 0.8rem;
cursor: pointer;
}
.lang-tab.active {
background: var(--text-primary);
color: #000;
border-color: var(--text-primary);
font-weight: 600;
}
/* Related & Rec Cards (Horizontal Style) */
.scroll-card {
flex: 0 0 160px;
scroll-snap-align: start;
text-decoration: none;
color: var(--text-primary);
}
.scroll-card-img {
width: 100%;
aspect-ratio: 2/3;
border-radius: 6px;
object-fit: cover;
margin-bottom: 8px;
transition: transform 0.2s;
}
.scroll-card:hover .scroll-card-img { transform: scale(1.05); }
.scroll-card-title {
font-size: 0.9rem;
font-weight: 600;
line-height: 1.3;
display: -webkit-box;
-webkit-line-clamp: 2;
-webkit-box-orient: vertical;
overflow: hidden;
}
.scroll-card-meta { font-size: 0.8rem; color: var(--text-secondary); }
/* =========================================
7. RESPONSIVE MEDIA QUERIES
========================================= */
@media (min-width: 769px) {
/* Show poster on Desktop */
.hero-poster-container { display: block; }
/* Make relations/recs grid on desktop? Or keep scroll?
Prompt said "Relations (scrollable horizontal list)" & "Recommendations (scrollable)" */
}
@media (max-width: 768px) {
.series-hero-section { height: auto; min-height: auto; padding-top: 250px; background-position: center top; }
.series-hero-content { display: block; }
.series-title-text { font-size: 2rem; margin-top: 0; }
.hero-actions { display: grid; grid-template-columns: 1fr 1fr; }
.hero-watch-btn { grid-column: span 2; width: 100%; }
.details-grid { grid-template-columns: 1fr; gap: 1rem; }
}

647
animex/Resources/styles.css Normal file
View File

@@ -0,0 +1,647 @@
:root {
--bg-dark: #1C1C1E;
--bg-content-area: #1C1C1E;
--bg-content-elements: #3A3A3C;
--text-primary: #FFFFFF;
--text-secondary: #EBEBF599;
--text-tertiary: #8A8A8E;
--accent-color-active: #FF9500;
--border-color: #38383A;
--button-bg: #4A4A4F;
--button-secondary-bg: rgba(74, 74, 79, 0.7);
/* Desktop-specific variables */
--sidebar-width: 280px;
--content-max-width: 1400px;
--desktop-padding: 40px;
--card-hover-lift: translateY(-4px);
--transition-smooth: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
/* NEW: Consistent border radius variables */
--radius-large: 16px;
--radius-medium: 12px;
--radius-small: 8px;
}
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
body {
font-family: 'Inter', sans-serif;
background-color: var(--bg-dark);
color: var(--text-primary);
display: flex;
flex-direction: column;
height: 100vh;
overflow: hidden;
}
/*
================================================================================
DESKTOP STYLES (REWORKED & CONSOLIDATED)
================================================================================
*/
@media (min-width: 768px) {
/* === BASE LAYOUT === */
.app-container {
display: flex;
flex-direction: row;
flex-grow: 1;
height: 100vh;
overflow: hidden;
}
.desktop-main {
flex-grow: 1;
display: flex;
flex-direction: column;
overflow-y: auto; /* Scroll is on the main container now */
background: var(--bg-content-area);
}
/* Hide mobile bottom nav on desktop */
.bottom-nav {
display: none;
}
/* === DESKTOP SIDEBAR NAVIGATION === */
.desktop-sidebar {
width: var(--sidebar-width);
flex-shrink: 0;
background: linear-gradient(180deg, rgba(28, 28, 30, 0.95) 0%, rgba(28, 28, 30, 0.98) 100%);
backdrop-filter: blur(20px);
border-right: 1px solid rgba(255, 255, 255, 0.08);
display: flex;
flex-direction: column;
padding: var(--desktop-padding) 24px;
position: relative;
z-index: 50;
}
.desktop-sidebar::before {
content: '';
position: absolute;
top: 0;
right: 0;
width: 1px;
height: 100%;
background: linear-gradient(180deg, transparent 0%, rgba(255, 149, 0, 0.3) 20%, rgba(255, 149, 0, 0.1) 50%, rgba(255, 149, 0, 0.3) 80%, transparent 100%);
}
.sidebar-brand {
display: flex;
align-items: center;
gap: 12px;
margin-bottom: 40px;
padding-bottom: 20px;
border-bottom: 1px solid rgba(255, 255, 255, 0.06);
}
.sidebar-brand .brand-icon {
width: 36px;
height: 36px;
background: linear-gradient(135deg, #FF9500 0%, #FF6B00 100%);
border-radius: var(--radius-small);
display: flex;
align-items: center;
justify-content: center;
font-size: 20px;
color: white;
box-shadow: 0 4px 12px rgba(255, 149, 0, 0.3);
}
.sidebar-brand .brand-name {
font-size: 1.5rem;
font-weight: 700;
background: linear-gradient(135deg, #FFFFFF 0%, #E0E0E0 100%);
background-clip: text;
-webkit-background-clip: text;
-webkit-text-fill-color: transparent;
}
.sidebar-nav {
flex-grow: 1;
display: flex;
flex-direction: column;
gap: 8px;
}
.sidebar-nav-item {
display: flex;
align-items: center;
gap: 16px;
padding: 16px 20px;
border-radius: var(--radius-medium);
text-decoration: none;
color: var(--text-secondary);
font-weight: 500;
transition: var(--transition-smooth);
position: relative;
overflow: hidden;
}
.sidebar-nav-item::before {
content: '';
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
background: linear-gradient(135deg, rgba(255, 149, 0, 0.1) 0%, rgba(255, 149, 0, 0.05) 100%);
opacity: 0;
transition: var(--transition-smooth);
}
.sidebar-nav-item:hover {
color: var(--text-primary);
transform: translateX(4px);
box-shadow: 0 4px 20px rgba(0, 0, 0, 0.1);
}
.sidebar-nav-item:hover::before {
opacity: 1;
}
.sidebar-nav-item.active {
background: linear-gradient(135deg, rgba(255, 149, 0, 0.15) 0%, rgba(255, 149, 0, 0.08) 100%);
color: var(--accent-color-active);
border: 1px solid rgba(255, 149, 0, 0.2);
}
.sidebar-nav-item.active::before {
opacity: 1;
}
.sidebar-nav-item .nav-icon, .sidebar-nav-item .nav-text {
position: relative;
z-index: 1;
}
.sidebar-nav-item .nav-icon {
font-size: 20px;
width: 24px;
height: 24px;
display: flex;
align-items: center;
justify-content: center;
}
/* === HERO SECTION === */
.hero-section {
/* FIX: Use min-height to prevent content squishing on smaller viewports */
min-height: 400px;
background-size: cover;
background-position: center;
padding: var(--desktop-padding);
display: flex;
align-items: flex-end;
position: relative;
margin: 0;
border-radius: 0;
}
.hero-section::before {
content: '';
position: absolute;
inset: 0;
background: linear-gradient(135deg, rgba(0,0,0,0.4) 0%, rgba(0,0,0,0.1) 40%, rgba(0,0,0,0.7) 100%), linear-gradient(to top, rgba(0,0,0,0.8) 0%, rgba(0,0,0,0.1) 100%);
z-index: 1;
}
.hero-content {
max-width: var(--content-max-width);
width: 100%;
margin: 0 auto;
display: flex;
align-items: flex-end;
gap: 30px;
position: relative;
z-index: 2;
padding-bottom: 20px; /* Reduced padding slightly for balance */
}
.hero-poster img {
/* FIX: Increased size for more impact on desktop */
width: 180px;
height: 270px;
object-fit: cover;
border-radius: var(--radius-medium);
border: 2px solid rgba(255, 255, 255, 0.1);
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.4);
transition: var(--transition-smooth);
}
.hero-poster img:hover {
transform: scale(1.05);
box-shadow: 0 12px 48px rgba(0, 0, 0, 0.6);
}
.hero-details .title-logo {
/* FIX: clamp() makes font size fluid and responsive */
font-size: clamp(2.5rem, 4vw, 3.5rem);
font-weight: 800;
margin-bottom: 16px;
text-shadow: 0 4px 12px rgba(0, 0, 0, 0.5);
}
.hero-details .episode-info {
font-size: 1rem;
padding: 8px 16px;
border-radius: var(--radius-small);
margin-bottom: 12px;
}
.continue-button {
padding: 14px 28px;
font-size: 1.1rem;
border-radius: var(--radius-small);
transition: var(--transition-smooth);
border: 1px solid rgba(255, 255, 255, 0.2);
}
.continue-button:hover {
background-color: rgba(255, 149, 0, 0.2);
border-color: rgba(255, 149, 0, 0.4);
transform: translateY(-2px);
box-shadow: 0 8px 24px rgba(255, 149, 0, 0.3);
}
/* === MAIN CONTENT BODY === */
.main-content {
/* This is the inner container that centers the content */
padding: var(--desktop-padding);
max-width: var(--content-max-width);
margin: 0 auto;
width: 100%;
}
.section-title {
font-size: 2rem;
font-weight: 700;
margin-bottom: 24px;
background: linear-gradient(135deg, #FFFFFF 0%, #E0E0E0 100%);
background-clip: text;
-webkit-background-clip: text;
-webkit-text-fill-color: transparent;
}
/* FIX: Reduced minmax() width to make the grid more responsive on smaller desktop screens */
.item-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(350px, 1fr));
gap: 24px;
margin-bottom: 40px;
}
.item-list {
display: flex;
flex-direction: column;
gap: 16px;
}
.list-item {
background: rgba(58, 58, 60, 0.4);
backdrop-filter: blur(20px);
border-radius: var(--radius-large);
border: 1px solid rgba(255, 255, 255, 0.08);
box-shadow: 0 4px 24px rgba(0, 0, 0, 0.15), inset 0 1px 0 rgba(255, 255, 255, 0.1);
padding: 24px;
display: flex;
align-items: center;
gap: 20px;
cursor: pointer;
transition: var(--transition-smooth);
position: relative;
overflow: hidden;
}
.list-item::before {
content: '';
position: absolute;
inset: 0;
background: linear-gradient(135deg, rgba(255, 149, 0, 0.05) 0%, rgba(255, 149, 0, 0.02) 100%);
opacity: 0;
transition: var(--transition-smooth);
}
.list-item:hover {
background: rgba(58, 58, 60, 0.6);
transform: var(--card-hover-lift);
box-shadow: 0 8px 40px rgba(0, 0, 0, 0.25), inset 0 1px 0 rgba(255, 255, 255, 0.15), 0 0 0 1px rgba(255, 149, 0, 0.1);
border-color: rgba(255, 149, 0, 0.2);
}
.list-item:hover::before {
opacity: 1;
}
.item-thumbnail img {
/* FIX: Increased size and set aspect ratio for better visual weight */
width: 90px;
height: 135px;
object-fit: cover;
border-radius: var(--radius-medium);
border: 1px solid rgba(255, 255, 255, 0.1);
transition: var(--transition-smooth);
flex-shrink: 0;
}
.list-item:hover .item-thumbnail img {
transform: scale(1.05);
box-shadow: 0 4px 16px rgba(0, 0, 0, 0.3);
}
.item-title {
font-size: 1.25rem;
font-weight: 600;
margin-bottom: 8px;
}
.item-description {
font-size: 0.95rem;
line-height: 1.5;
margin-top: 8px;
color: var(--text-secondary);
}
.meta-pill {
background-color: rgba(58, 58, 60, 0.8);
color: var(--text-secondary);
font-size: 0.8rem;
padding: 6px 12px;
border-radius: 20px;
font-weight: 500;
border: 1px solid rgba(255, 255, 255, 0.06);
}
.item-arrow .fa-chevron-right {
color: var(--text-tertiary);
font-size: 1.1rem;
transition: var(--transition-smooth);
}
.list-item:hover .item-arrow .fa-chevron-right {
color: var(--accent-color-active);
transform: translateX(4px);
}
/* === SEARCH BAR === */
.search-bar-container {
/* FIX: Center the search bar within the main content area */
margin: 0 auto 32px auto;
width: 100%;
max-width: 600px;
background: rgba(58, 58, 60, 0.4);
backdrop-filter: blur(20px);
padding: 16px 24px;
border-radius: var(--radius-large);
border: 1px solid rgba(255, 255, 255, 0.08);
transition: var(--transition-smooth);
}
.search-bar-container:focus-within {
border-color: rgba(255, 149, 0, 0.4);
box-shadow: 0 0 0 4px rgba(255, 149, 0, 0.1);
background: rgba(58, 58, 60, 0.6);
}
.search-bar-container input[type="text"] {
font-size: 1.1rem;
padding: 4px 0;
}
.search-bar-container .fa-search {
font-size: 1.4rem;
margin-left: 16px;
transition: var(--transition-smooth);
}
.search-bar-container:focus-within .fa-search {
color: var(--accent-color-active);
}
/* === SETTINGS PAGE === */
.settings-group {
margin-bottom: 40px;
background: rgba(58, 58, 60, 0.3);
backdrop-filter: blur(20px);
border-radius: var(--radius-large);
padding: 24px;
border: 1px solid rgba(255, 255, 255, 0.08);
}
.settings-user-profile {
padding-bottom: 24px;
margin-bottom: 24px;
gap: 20px;
}
.settings-user-profile img {
width: 64px;
height: 64px;
border: 2px solid rgba(255, 149, 0, 0.3);
}
.settings-user-profile .username {
font-size: 1.4rem;
font-weight: 600;
}
.settings-item {
padding: 20px 12px;
font-size: 1.1rem;
transition: var(--transition-smooth);
border-radius: var(--radius-small);
margin: 0 -12px;
}
.settings-item:hover {
background: rgba(255, 255, 255, 0.03);
}
.toggle-switch {
width: 60px;
height: 32px;
}
.slider:before {
height: 24px;
width: 24px;
left: 4px;
bottom: 4px;
}
input:checked + .slider:before {
transform: translateX(28px);
}
/* === SCROLLBAR STYLING === */
.desktop-main::-webkit-scrollbar {
width: 10px;
}
.desktop-main::-webkit-scrollbar-track {
background: transparent;
}
.desktop-main::-webkit-scrollbar-thumb {
background: rgba(255, 255, 255, 0.15);
border-radius: 10px;
border: 2px solid var(--bg-dark);
}
.desktop-main::-webkit-scrollbar-thumb:hover {
background: var(--accent-color-active);
}
}
/*
================================================================================
MOBILE STYLES (UNCHANGED)
================================================================================
*/
@media (max-width: 767px) {
.app-container {
display: flex;
flex-direction: column;
flex-grow: 1;
overflow-y: auto;
}
.desktop-sidebar {
display: none;
}
.desktop-main {
display: contents;
}
/* Original mobile hero section */
.hero-section {
height: 300px;
background-size: cover;
background-position: center;
padding: 20px;
display: flex;
align-items: flex-end;
position: relative;
}
.hero-section::before {
content: '';
position: absolute;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: linear-gradient(to bottom, rgba(0,0,0,0.1) 0%, rgba(0,0,0,0.7) 100%);
z-index: 1;
}
.hero-content {
display: flex;
align-items: flex-end;
gap: 15px;
position: relative;
z-index: 2;
width: 100%;
padding-bottom: 20px;
}
.hero-poster img {
width: 100px;
height: auto;
border-radius: 8px;
border: 1px solid #555;
}
.hero-details .title-logo {
font-size: 1.8em;
font-weight: bold;
color: var(--text-primary);
margin-bottom: 10px;
}
.main-content {
flex-grow: 1;
background-color: var(--bg-content-area);
padding: 20px;
position: relative;
z-index: 2;
}
.section-title {
font-size: 1.6em;
font-weight: 700;
margin-bottom: 15px;
}
.item-list {
display: flex;
flex-direction: column;
gap: 15px;
}
.list-item {
background: rgba(58, 58, 60, 0.5);
backdrop-filter: blur(12px);
-webkit-backdrop-filter: blur(12px);
border-radius: 12px;
border: 1px solid rgba(255, 255, 255, 0.1);
box-shadow: 0 4px 30px rgba(0, 0, 0, 0.2);
padding: 15px;
display: flex;
align-items: center;
gap: 15px;
cursor: pointer;
transition: background 0.3s ease;
}
.list-item:hover {
background: rgba(58, 58, 60, 0.7);
}
/* Mobile bottom navigation */
.bottom-nav {
display: flex;
justify-content: space-around;
background-color: var(--bg-dark);
padding: 10px 0;
border-top: 1px solid var(--border-color);
position: sticky;
bottom: 0;
left: 0;
right: 0;
z-index: 100;
}
.nav-item {
display: flex;
flex-direction: column;
align-items: center;
text-decoration: none;
color: var(--text-tertiary);
font-size: 0.75em;
font-weight: 500;
flex-grow: 1;
padding: 5px 0;
margin-bottom: 10px;
}
.nav-item .nav-icon {
font-size: 1.5em;
margin-bottom: 4px;
width: 24px;
height: 24px;
display: flex;
align-items: center;
justify-content: center;
}
.nav-item.active {
color: var(--accent-color-active);
}
}

111
animex/about.html Normal file
View File

@@ -0,0 +1,111 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>About The Developer</title>
<link rel="preconnect" href="https://fonts.googleapis.com" />
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;700&display=swap" rel="stylesheet" />
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.5.1/css/all.min.css" />
<style>
:root {
--font-sans: "Inter", -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif;
--accent-primary: #ff9500;
--foreground-primary: #e6e6e6;
--foreground-secondary: #a0a0a0;
}
body {
font-family: var(--font-sans);
background: linear-gradient(45deg, #101010, #1a1a1a);
color: var(--foreground-primary);
display: flex;
justify-content: center;
align-items: center;
min-height: 100vh;
margin: 0;
padding: 1rem;
box-sizing: border-box;
-webkit-font-smoothing: antialiased;
}
.glass-card {
background: rgba(30, 30, 30, 0.6);
backdrop-filter: blur(20px) saturate(180%);
-webkit-backdrop-filter: blur(20px) saturate(180%);
border-radius: 24px;
border: 1px solid rgba(255, 255, 255, 0.1);
padding: 2.5rem;
text-align: center;
max-width: 350px;
width: 100%;
box-shadow: 0 8px 32px 0 rgba(0, 0, 0, 0.37);
}
.avatar {
width: 120px;
height: 120px;
border-radius: 50%;
object-fit: cover;
border: 3px solid var(--accent-primary);
margin-bottom: 1.5rem;
box-shadow: 0 0 20px rgba(255, 149, 0, 0.4);
}
h1 {
font-size: 2rem;
font-weight: 700;
margin: 0 0 0.25rem 0;
}
h2 {
font-size: 1rem;
font-weight: 500;
color: var(--accent-primary);
margin: 0 0 1.5rem 0;
letter-spacing: 0.5px;
}
p {
font-size: 1rem;
line-height: 1.6;
color: var(--foreground-secondary);
margin: 0 0 2rem 0;
}
.social-links {
display: flex;
justify-content: center;
gap: 1.5rem;
}
.social-links a {
color: var(--foreground-secondary);
font-size: 1.5rem;
transition: color 0.3s ease, transform 0.3s ease;
}
.social-links a:hover {
color: var(--accent-primary);
transform: scale(1.1);
}
</style>
</head>
<body>
<div class="glass-card">
<img src="https://images.unsplash.com/photo-1516298252535-cf2ac5147f9b?q=80&w=774&auto=format&fit=crop&ixlib=rb-4.1.0&ixid=M3wxMjA3fDB8MHxwaG90by1wYWdlfHx8fGVufDB8fHx8fA%3D%3D" alt="Developer Avatar" class="avatar" />
<h1>arkm</h1>
<h2>Creator & Developer</h2>
<p>
Just a passionate developer building cool things.
Thanks for using the app!
</p>
<div class="social-links">
<a href="#" target="_blank" title="GitHub"><i class="fab fa-github"></i></a>
<a href="#" target="_blank" title="Website"><i class="fas fa-globe"></i></a>
<a href="#" target="_blank" title="Twitter / X"><i class="fab fa-twitter"></i></a>
</div>
</div>
</body>
</html>

1390
animex/anime.html Normal file

File diff suppressed because it is too large Load Diff

1
animex/config.ini Normal file
View File

@@ -0,0 +1 @@
{"GOOGLE_API_KEY": "AIzaSyDGfMj_Hyaw2uJKKrz_nvsgtAMf3tHRS-I"}

304
animex/content.json Normal file
View File

@@ -0,0 +1,304 @@
{
"spotlight": [
{
"id": 52299,
"name": "Solo Leveling",
"image_tall": "https://imgsrv.crunchyroll.com/cdn-cgi/image/fit=contain,format=auto,quality=85,width=1680,height=2520/keyart/GDKHZEJ0K-backdrop_tall",
"image": "https://imgsrv.crunchyroll.com/cdn-cgi/image/fit=cover,format=auto,quality=85,width=3840/keyart/GDKHZEJ0K-backdrop_wide",
"logo": "https://imgsrv.crunchyroll.com/cdn-cgi/image/fit=contain,format=auto,quality=85,width=600/keyart/GDKHZEJ0K-title_logo-en-us"
},
{
"id": 57658,
"name": "Jujutsu Kaisen: Culling Game Part 1",
"image": "https://images4.alphacoders.com/138/1389939.jpg",
"logo": "https://imgsrv.crunchyroll.com/cdn-cgi/image/fit=contain,format=auto,quality=85,width=600/keyart/GRDV0019R-title_logo-en-us"
},
{
"id": 52991,
"name": "Frieren: Beyond Journey's End",
"image": "https://res.cloudinary.com/dsa5iolcx/image/upload/v1774835260/GG5H5XQX4-backdrop_wide.webp",
"image_tall": "https://res.cloudinary.com/dsa5iolcx/image/upload/v1774835256/GG5H5XQX4-backdrop_tall.webp",
"logo": "https://imgsrv.crunchyroll.com/cdn-cgi/image/fit=contain,format=auto,quality=85,width=600/keyart/GG5H5XQX4-title_logo-en-us"
},
{
"id": 21,
"name": "One Piece",
"image": "https://imgsrv.crunchyroll.com/cdn-cgi/image/fit=cover,format=auto,quality=85,width=1920/keyart/GRMG8ZQZR-backdrop_wide",
"image_tall": "https://imgsrv.crunchyroll.com/cdn-cgi/image/fit=contain,format=auto,quality=85,width=1680,height=2520/keyart/GRMG8ZQZR-backdrop_tall",
"logo": "https://imgsrv.crunchyroll.com/cdn-cgi/image/fit=contain,format=auto,quality=85,width=600/keyart/GRMG8ZQZR-title_logo-en-us"
},
{
"id": 52588,
"name": "Kaiju No. 8",
"image": "https://imgsrv.crunchyroll.com/cdn-cgi/image/fit=cover,format=auto,quality=85,width=3840/keyart/GG5H5XQ7D-backdrop_wide",
"logo": "https://imgsrv.crunchyroll.com/cdn-cgi/image/fit=contain,format=auto,quality=85,width=600/keyart/GG5H5XQ7D-title_logo-en-us"
}
],
"sections": [
{
"title": "Fresh Out of Japan",
"items": [
{
"id": 57658,
"name": "Jujutsu Kaisen Season 3: The Culling Game",
"image": "https://myanimelist.net/images/anime/1659/154920l.jpg"
},
{
"id": 59978,
"name": "Frieren: Beyond Journey's End Season 2",
"image": "https://myanimelist.net/images/anime/1921/154528l.jpg"
},
{
"id": 51553,
"name": "Witch Hat Atelier",
"image": "https://myanimelist.net/images/anime/1726/155542l.jpg"
},
{
"id": 58012,
"name": "[Oshi No Ko] Season 3",
"image": "https://myanimelist.net/images/anime/1873/141269l.jpg"
},
{
"id": 61469,
"name": "JoJo's Bizarre Adventure: Steel Ball Run",
"image": "https://myanimelist.net/images/anime/1448/154111l.jpg"
},
{
"id": 55825,
"name": "Hell's Paradise Season 2",
"image": "https://myanimelist.net/images/anime/1772/154456l.jpg"
},
{
"id": 56023,
"name": "Re:ZERO -Starting Life in Another World- Season 4",
"image": "https://myanimelist.net/images/anime/1275/137252l.jpg"
},
{
"id": 57034,
"name": "Daemons of the Shadow Realm",
"image": "https://myanimelist.net/images/anime/1880/139404l.jpg"
},
{
"id": 21,
"name": "One Piece: Elbaph Arc",
"image": "https://myanimelist.net/images/anime/1244/138851l.jpg"
},
{
"id": 57592,
"name": "Dr. STONE: SCIENCE FUTURE",
"image": "https://myanimelist.net/images/anime/1403/146479l.jpg"
}
]
},
{
"title": "Award Winners",
"items": [
{
"id": 51009,
"name": "Jujutsu Kaisen",
"image": "https://myanimelist.net/images/anime/1792/138022l.jpg"
},
{
"id": 52034,
"name": "[Oshi No Ko]",
"image": "https://myanimelist.net/images/anime/1812/134736l.jpg"
},
{
"id": 49387,
"name": "Vinland Saga Season 2",
"image": "https://myanimelist.net/images/anime/1170/124312l.jpg"
},
{
"id": 50602,
"name": "Spy x Family",
"image": "https://myanimelist.net/images/anime/1111/127508l.jpg"
},
{
"id": 41467,
"name": "Bleach: Thousand-Year Blood War",
"image": "https://myanimelist.net/images/anime/1908/135431l.jpg"
},
{
"id": 44511,
"name": "Chainsaw Man",
"image": "https://myanimelist.net/images/anime/1806/126216l.jpg"
},
{
"id": 51019,
"name": "Demon Slayer: Kimetsu no Yaiba",
"image": "https://myanimelist.net/images/anime/1765/135099l.jpg"
},
{
"id": 40834,
"name": "Ranking of Kings",
"image": "https://myanimelist.net/images/anime/1347/117616l.jpg"
},
{
"id": 52299,
"name": "Solo Leveling",
"image": "https://myanimelist.net/images/anime/1801/142390l.jpg"
},
{
"id": 52991,
"name": "Frieren: Beyond Journey's End",
"image": "https://myanimelist.net/images/anime/1015/138006l.jpg"
}
]
},
{
"title": "Global Fan Favorites",
"items": [
{
"id": 38000,
"name": "Demon Slayer: Kimetsu no Yaiba",
"image": "https://myanimelist.net/images/anime/1286/99889l.jpg"
},
{
"id": 40748,
"name": "Jujutsu Kaisen",
"image": "https://myanimelist.net/images/anime/1171/109222l.jpg"
},
{
"id": 31964,
"name": "My Hero Academia",
"image": "https://myanimelist.net/images/anime/10/78745l.jpg"
},
{
"id": 16498,
"name": "Attack on Titan",
"image": "https://myanimelist.net/images/anime/10/47347l.jpg"
},
{
"id": 21,
"name": "One Piece",
"image": "https://myanimelist.net/images/anime/1244/138851l.jpg"
},
{
"id": 50265,
"name": "Spy x Family",
"image": "https://myanimelist.net/images/anime/1441/122795l.jpg"
},
{
"id": 44511,
"name": "Chainsaw Man",
"image": "https://myanimelist.net/images/anime/1806/126216l.jpg"
},
{
"id": 269,
"name": "Bleach",
"image": "https://myanimelist.net/images/anime/1541/147774l.jpg"
},
{
"id": 52299,
"name": "Solo Leveling",
"image": "https://myanimelist.net/images/anime/1801/142390l.jpg"
},
{
"id": 20,
"name": "Naruto",
"image": "https://myanimelist.net/images/anime/1141/142503l.jpg"
}
]
},
{
"title": "Gateway to Anime",
"items": [
{
"id": 31964,
"name": "My Hero Academia",
"image": "https://myanimelist.net/images/anime/10/78745l.jpg"
},
{
"id": 1535,
"name": "Death Note",
"image": "https://myanimelist.net/images/anime/1079/138100l.jpg"
},
{
"id": 16498,
"name": "Attack on Titan",
"image": "https://myanimelist.net/images/anime/10/47347l.jpg"
},
{
"id": 38000,
"name": "Demon Slayer: Kimetsu no Yaiba",
"image": "https://myanimelist.net/images/anime/1286/99889l.jpg"
},
{
"id": 32281,
"name": "Your Name.",
"image": "https://myanimelist.net/images/anime/5/87048l.jpg"
},
{
"id": 527,
"name": "Pokémon",
"image": "https://myanimelist.net/images/anime/1787/140239l.jpg"
},
{
"id": 813,
"name": "Dragon Ball Z",
"image": "https://myanimelist.net/images/anime/1277/142022l.jpg"
},
{
"id": 20,
"name": "Naruto",
"image": "https://myanimelist.net/images/anime/1141/142503l.jpg"
}
]
},
{
"title": "Action-Packed",
"items": [
{
"id": 40748,
"name": "Jujutsu Kaisen",
"image": "https://myanimelist.net/images/anime/1171/109222l.jpg"
},
{
"id": 38000,
"name": "Demon Slayer: Kimetsu no Yaiba",
"image": "https://myanimelist.net/images/anime/1286/99889l.jpg"
},
{
"id": 31964,
"name": "My Hero Academia",
"image": "https://myanimelist.net/images/anime/10/78745l.jpg"
},
{
"id": 16498,
"name": "Attack on Titan",
"image": "https://myanimelist.net/images/anime/10/47347l.jpg"
},
{
"id": 44511,
"name": "Chainsaw Man",
"image": "https://myanimelist.net/images/anime/1806/126216l.jpg"
},
{
"id": 50265,
"name": "Spy x Family",
"image": "https://myanimelist.net/images/anime/1441/122795l.jpg"
},
{
"id": 459,
"name": "One Piece: The Movie",
"image": "https://myanimelist.net/images/anime/1770/97704l.jpg"
},
{
"id": 269,
"name": "Bleach",
"image": "https://myanimelist.net/images/anime/1541/147774l.jpg"
},
{
"id": 38691,
"name": "Dr. Stone",
"image": "https://myanimelist.net/images/anime/1613/102576l.jpg"
},
{
"id": 58567,
"name": "Solo Leveling Season 2: Arise from the Shadow",
"image": "https://myanimelist.net/images/anime/1448/147351l.jpg"
}
]
}
]
}

201
animex/cover.py Normal file
View File

@@ -0,0 +1,201 @@
import json
import requests
import time
import re
def normalize_string(s):
"""Removes spaces, punctuation, and lowercases the string for robust comparison."""
if not s:
return ""
return re.sub(r'[^a-z0-9]', '', str(s).lower())
def is_name_match(json_name, api_data):
"""Checks if the JSON name matches any of the anime's titles from MAL."""
norm_json = normalize_string(json_name)
# Gather all possible titles from the API response
titles =[]
if api_data.get("title"): titles.append(api_data["title"])
if api_data.get("title_english"): titles.append(api_data["title_english"])
if api_data.get("title_japanese"): titles.append(api_data["title_japanese"])
# Jikan v4 also provides a 'titles' array
for t_obj in api_data.get("titles",[]):
if "title" in t_obj:
titles.append(t_obj["title"])
for syn in api_data.get("title_synonyms",[]):
titles.append(syn)
# Check for a match
for t in titles:
norm_t = normalize_string(t)
# Using "in" allows us to match variations like "Spy x Family Part 2" with "Spy x Family Cour 2"
# as long as the base string closely aligns, but it leans toward exact matches.
if norm_json == norm_t or norm_json in norm_t or norm_t in norm_json:
return True
return False
def get_anime_by_id(mal_id, retries=5):
"""Fetches anime details by MAL ID with retry and rate-limit handling."""
url = f"https://api.jikan.moe/v4/anime/{mal_id}"
attempt = 0
while attempt < retries:
try:
response = requests.get(url, timeout=10)
if response.status_code == 200:
return response.json().get("data")
elif response.status_code == 404:
return None # ID does not exist
elif response.status_code == 429:
print(" [!] Rate limited. Waiting 1 second...")
time.sleep(1)
attempt += 1
else:
print(f" [!] HTTP Error {response.status_code}. Retrying...")
time.sleep(1)
attempt += 1
except requests.exceptions.RequestException:
print(" [!] Network error. Retrying...")
time.sleep(1)
attempt += 1
return None
def search_anime(query, retries=5):
"""Searches Jikan for the anime by name. Sorts by 'members' (popularity)."""
url = "https://api.jikan.moe/v4/anime"
params = {
"q": query,
"limit": 7
}
attempt = 0
while attempt < retries:
try:
response = requests.get(url, params=params, timeout=10)
if response.status_code == 200:
return response.json().get("data",[])
elif response.status_code == 429:
print(" [!] Rate limited. Waiting 1 second...")
time.sleep(1)
attempt += 1
else:
print(f" [!] HTTP Error {response.status_code}. Retrying...")
time.sleep(1)
attempt += 1
except requests.exceptions.RequestException:
print(" [!] Network error. Retrying...")
time.sleep(1)
attempt += 1
return[]
def prompt_user_choice(anime_name, current_id, results):
"""Displays the search results and asks the user to pick the correct one."""
print(f"\n==================================================")
print(f"🔍 Searching for manually: {anime_name} (Current ID: {current_id})")
print(f"==================================================")
if not results:
print(" [!] No results found on MyAnimeList.")
return None
for i, res in enumerate(results):
title = res.get("title_english") or res.get("title")
media_type = res.get("type", "Unknown")
year = res.get("year", "N/A")
mal_id = res["mal_id"]
print(f" [{i + 1}] {title} ({media_type}, {year}) - ID: {mal_id}")
print(" [0] Skip / Keep current")
print(" [9] Enter a custom MAL ID manually")
while True:
try:
choice = input("\nSelect the correct anime (0-5, or 9): ").strip()
if choice == "":
continue
choice = int(choice)
if choice == 0:
print(" -> Skipping. Kept original data.")
return None
elif choice == 9:
custom_id = int(input(" -> Enter custom MAL ID: ").strip())
return get_anime_by_id(custom_id)
elif 1 <= choice <= len(results):
return results[choice - 1]
else:
print(" [!] Invalid choice. Please enter a number from the list.")
except ValueError:
print(" [!] Please enter a valid number.")
def main():
file_path = "content.json"
try:
with open(file_path, "r", encoding="utf-8") as file:
data = json.load(file)
except FileNotFoundError:
print(f"Error: Could not find '{file_path}'.")
return
for section in data.get("sections", []):
for item in section.get("items",[]):
name = item.get("name")
current_id = item.get("id")
current_image = item.get("image")
if not name or not current_id:
continue
print(f"\nChecking: {name} (ID: {current_id})")
# 1. Fetch data for the current ID
api_data = get_anime_by_id(current_id)
time.sleep(0.4) # Respect Jikan's 3 requests/second rate limit
# 2. Check if ID matches Name
if api_data and is_name_match(name, api_data):
# Name matches! Now verify the image
images = api_data.get("images", {}).get("jpg", {})
best_image = images.get("large_image_url") or images.get("image_url")
if current_image == best_image:
print(" ✅ ID and Image are both correct. Skipping.")
else:
print(" ⚠️ ID is correct, but Image is outdated. Updating image automatically...")
if best_image:
item["image"] = best_image
print(f" ✅ Updated Image: {best_image}")
else:
# Name does not match or ID is invalid. Prompt user.
print(" ❌ ID does NOT match the Name (or ID is invalid). Searching MAL...")
results = search_anime(name)
time.sleep(0.4)
selected_anime = prompt_user_choice(name, current_id, results)
if selected_anime:
new_id = selected_anime["mal_id"]
images = selected_anime.get("images", {}).get("jpg", {})
new_image_url = images.get("large_image_url") or images.get("image_url")
item["id"] = new_id
if new_image_url:
item["image"] = new_image_url
print(f" ✅ Updated '{name}' -> ID: {new_id} | Image: {new_image_url}")
# Save the updated JSON back to the file
with open(file_path, "w", encoding="utf-8") as file:
json.dump(data, file, indent=4, ensure_ascii=False)
print("\n🎉 Done! All selected IDs and Images have been verified/updated and saved to 'content.json'.")
if __name__ == "__main__":
main()

457
animex/down.html Normal file
View File

@@ -0,0 +1,457 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Download Anime Series</title>
<style>
@import url('https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700&display=swap');
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
:root {
--primary-color: #FF9500;
--primary-dark: #E68600;
--primary-light: #FFB84D;
--bg-dark: #0F0F0F;
--bg-secondary: #1A1A1A;
--bg-tertiary: #252525;
--text-primary: #FFFFFF;
--text-secondary: #B3B3B3;
--text-muted: #808080;
--border-color: #333333;
--border-light: #404040;
--success-color: #10B981;
--error-color: #FF9500;
--glass-bg: rgba(255, 255, 255, 0.05);
--glass-border: rgba(255, 255, 255, 0.1);
}
body {
font-family: 'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
background: linear-gradient(135deg, #0F0F0F 0%, #1A1A1A 100%);
color: var(--text-primary);
margin: 0;
padding: 2rem;
min-height: 100vh;
display: flex;
justify-content: center;
align-items: center;
}
.container {
background: var(--glass-bg);
backdrop-filter: blur(20px);
border: 1px solid var(--glass-border);
padding: 2.5rem;
border-radius: 24px;
box-shadow: 0 25px 50px rgba(0, 0, 0, 0.4);
width: 100%;
max-width: 650px;
position: relative;
overflow: hidden;
}
.container::before {
content: '';
position: absolute;
top: 0;
left: 0;
right: 0;
height: 1px;
background: linear-gradient(90deg, transparent, var(--primary-color), transparent);
}
h1 {
background: linear-gradient(135deg, var(--primary-color), var(--primary-light));
-webkit-background-clip: text;
-webkit-text-fill-color: transparent;
background-clip: text;
text-align: center;
margin-bottom: 2rem;
font-weight: 700;
font-size: 2rem;
letter-spacing: -0.02em;
}
.series-list {
list-style: none;
padding: 0;
max-height: 45vh;
overflow-y: auto;
border: 1px solid var(--border-color);
border-radius: 16px;
background: var(--bg-secondary);
position: relative;
}
.series-list::-webkit-scrollbar {
width: 8px;
}
.series-list::-webkit-scrollbar-track {
background: var(--bg-tertiary);
border-radius: 8px;
}
.series-list::-webkit-scrollbar-thumb {
background: var(--primary-color);
border-radius: 8px;
border: 2px solid var(--bg-tertiary);
}
.series-list::-webkit-scrollbar-thumb:hover {
background: var(--primary-light);
}
.series-item {
display: flex;
align-items: center;
padding: 1.25rem;
border-bottom: 1px solid var(--border-color);
cursor: pointer;
transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
position: relative;
}
.series-item:last-child {
border-bottom: none;
}
.series-item.selected {
background: linear-gradient(135deg, rgba(255, 149, 0, 0.15), rgba(255, 149, 0, 0.08));
border-left: 3px solid var(--primary-color);
}
/* Custom Checkbox */
.custom-checkbox {
position: relative;
display: inline-block;
width: 24px;
height: 24px;
margin-right: 1rem;
cursor: pointer;
}
.custom-checkbox input[type="checkbox"] {
opacity: 0;
width: 100%;
height: 100%;
position: absolute;
cursor: pointer;
}
.checkbox-visual {
position: absolute;
top: 0;
left: 0;
width: 24px;
height: 24px;
border: 2px solid var(--border-light);
border-radius: 8px;
background: var(--bg-tertiary);
transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
display: flex;
align-items: center;
justify-content: center;
}
.checkbox-visual::after {
content: '';
width: 8px;
height: 8px;
border: 2px solid var(--text-primary);
border-top: none;
border-right: none;
transform: rotate(-45deg) scale(0);
transition: transform 0.2s cubic-bezier(0.4, 0, 0.2, 1);
}
.custom-checkbox input[type="checkbox"]:checked + .checkbox-visual {
background: var(--primary-color);
border-color: var(--primary-color);
box-shadow: 0 0 0 3px rgba(255, 149, 0, 0.2);
}
.custom-checkbox input[type="checkbox"]:checked + .checkbox-visual::after {
transform: rotate(-45deg) scale(1);
border-color: var(--text-primary);
}
.custom-checkbox:hover .checkbox-visual {
border-color: var(--primary-color);
box-shadow: 0 0 0 3px rgba(255, 149, 0, 0.1);
}
.series-item label {
flex-grow: 1;
font-size: 1.1rem;
font-weight: 500;
color: var(--text-primary);
cursor: pointer;
transition: color 0.2s ease;
line-height: 1.4;
}
.series-item:hover label {
color: var(--primary-light);
}
.button-container {
text-align: center;
margin-top: 2rem;
}
.btn {
background: linear-gradient(135deg, var(--primary-color), var(--primary-dark));
color: var(--text-primary);
border: none;
padding: 1rem 2.5rem;
font-size: 1.1rem;
font-weight: 600;
border-radius: 16px;
cursor: pointer;
transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
box-shadow: 0 8px 25px rgba(255, 149, 0, 0.3);
position: relative;
overflow: hidden;
}
.btn::before {
content: '';
position: absolute;
top: 0;
left: -100%;
width: 100%;
height: 100%;
background: linear-gradient(90deg, transparent, rgba(255, 255, 255, 0.2), transparent);
transition: left 0.5s ease;
}
.btn:hover {
transform: translateY(-2px);
box-shadow: 0 12px 35px rgba(255, 149, 0, 0.4);
}
.btn:hover::before {
left: 100%;
}
.btn:active {
transform: translateY(0);
}
.btn:disabled {
background: linear-gradient(135deg, var(--text-muted), #666);
cursor: not-allowed;
transform: none;
box-shadow: none;
}
.btn:disabled::before {
display: none;
}
#loading-overlay {
position: fixed;
top: 0;
left: 0;
width: 100%;
height: 100%;
background: rgba(0, 0, 0, 0.9);
backdrop-filter: blur(10px);
display: none;
flex-direction: column;
justify-content: center;
align-items: center;
z-index: 1000;
}
.loader {
width: 80px;
height: 80px;
border: 4px solid var(--border-color);
border-top: 4px solid var(--primary-color);
border-radius: 50%;
animation: spin 1s linear infinite;
box-shadow: 0 0 20px rgba(255, 149, 0, 0.3);
}
#loading-text {
color: var(--text-primary);
margin-top: 24px;
font-size: 1.2rem;
font-weight: 500;
text-align: center;
opacity: 0.9;
}
@keyframes spin {
0% { transform: rotate(0deg); }
100% { transform: rotate(360deg); }
}
.error-message {
color: var(--error-color);
text-align: center;
font-weight: 500;
}
.empty-state {
text-align: center;
color: var(--text-secondary);
font-style: italic;
}
@media (max-width: 768px) {
body {
padding: 1rem;
}
.container {
padding: 1.5rem;
border-radius: 16px;
}
h1 {
font-size: 1.75rem;
}
.series-list {
max-height: 35vh;
}
.series-item {
padding: 1rem;
}
.btn {
padding: 0.875rem 2rem;
font-size: 1rem;
}
}
</style>
</head>
<body>
<div id="loading-overlay">
<div class="loader"></div>
<p id="loading-text">Generating your files...</p>
</div>
<div class="container">
<h1>Select Anime to Download</h1>
<form id="downloadForm">
<ul id="seriesList" class="series-list"></ul>
<div class="button-container">
<button type="submit" class="btn" id="generateBtn" disabled>Generate Zip</button>
</div>
</form>
</div>
<script>
document.addEventListener('DOMContentLoaded', async () => {
const seriesList = document.getElementById('seriesList');
const generateBtn = document.getElementById('generateBtn');
const loadingOverlay = document.getElementById('loading-overlay');
const apiBaseUrl = ''; // API is at the same origin
// Get the token from the URL parameters
const urlParams = new URLSearchParams(window.location.search);
const token = urlParams.get('token');
if (!token) {
seriesList.innerHTML = '<li class="series-item" style="justify-content: center;"><span class="error-message">Authentication token not found in URL.</span></li>';
generateBtn.disabled = true;
return;
}
try {
// Use the token from the URL for the Bearer authentication header
const response = await fetch(`${apiBaseUrl}/users/me`, {
headers: { 'Authorization': `Bearer ${token}` }
});
if (!response.ok) {
throw new Error('Failed to fetch user data. Your session may have expired.');
}
const userData = await response.json();
const history = userData.watch_history_detailed;
const uniqueSeries = [...new Set(Object.values(history).map(show => show.title))].sort();
if (uniqueSeries.length === 0) {
seriesList.innerHTML = '<li class="series-item" style="justify-content: center;"><span class="empty-state">No watched series found.</span></li>';
return;
}
uniqueSeries.forEach(title => {
const listItem = document.createElement('li');
listItem.className = 'series-item';
listItem.innerHTML = `
<div class="custom-checkbox">
<input type="checkbox" id="${title}" name="series" value="${title}">
<div class="checkbox-visual"></div>
</div>
<label for="${title}">${title}</label>
`;
// Add click handler for the entire item
listItem.addEventListener('click', (e) => {
if (e.target.tagName !== 'INPUT') {
const checkbox = listItem.querySelector('input[type="checkbox"]');
checkbox.checked = !checkbox.checked;
checkbox.dispatchEvent(new Event('change'));
}
});
// Add change handler for checkbox
const checkbox = listItem.querySelector('input[type="checkbox"]');
checkbox.addEventListener('change', () => {
if (checkbox.checked) {
listItem.classList.add('selected');
} else {
listItem.classList.remove('selected');
}
});
seriesList.appendChild(listItem);
});
generateBtn.disabled = false;
} catch (error) {
seriesList.innerHTML = `<li class="series-item" style="justify-content: center;"><span class="error-message">${error.message}</span></li>`;
}
document.getElementById('downloadForm').addEventListener('submit', (e) => {
e.preventDefault();
loadingOverlay.style.display = 'flex';
const selectedSeries = Array.from(document.querySelectorAll('input[name="series"]:checked')).map(cb => cb.value);
if (selectedSeries.length === 0) {
window.parent.showToast('Please select at least one series.');
loadingOverlay.style.display = 'none';
return;
}
const queryString = new URLSearchParams({ series_titles: selectedSeries.join(',') }).toString();
// Construct the download URL, passing the token as a query parameter
const downloadUrl = `${apiBaseUrl}/generate-zip?${queryString}&token=${token}`;
window.open(downloadUrl, '_blank');
setTimeout(() => {
loadingOverlay.style.display = 'none';
}, 3000);
});
});
</script>
</body>
</html>

579
animex/get.py Normal file
View File

@@ -0,0 +1,579 @@
import requests
import json
import time
import os
import collections
import getpass
import re
# --- Dependency Check and Setup ---
try:
import google.generativeai as genai
except ImportError:
print("Error: The 'google-generativeai' library is not installed.")
print("Please install it by running: pip install google-generativeai")
exit()
# --- Configuration ---
JIKAN_API_BASE_URL = "https://api.jikan.moe/v4"
CONTENT_FILE = "content.json"
CONFIG_FILE = "config.ini"
# Jikan rate limiting
REQUEST_TIMESTAMPS = collections.deque()
REQUEST_LIMIT = 3 # 3 requests per second
TIME_WINDOW = 1 # 1 second
# --- API Key and Configuration Management ---
def get_api_key():
"""Gets the Google AI API key, prompting the user if not found."""
# Check environment variable first
api_key = os.getenv("GOOGLE_API_KEY")
if api_key:
print("Loaded Google API Key from environment variable.")
input("Press Enter to continue...")
return api_key
# Check config file next
if os.path.exists(CONFIG_FILE):
with open(CONFIG_FILE, 'r') as f:
config = json.load(f)
api_key = config.get("GOOGLE_API_KEY")
if api_key:
print("Loaded Google API Key from config.ini.")
input("Press Enter to continue...")
return api_key
# If not found, prompt the user
print("\n--- Google AI API Key Required ---")
print("To use the AI generation feature, you need a Google AI API Key.")
print("You can get a free key from Google AI Studio.")
print("The key will be stored locally in 'config.ini' so you don't have to enter it again.")
api_key = getpass.getpass("Please enter your Google AI API Key: ")
# Save the key to config.ini for future use
with open(CONFIG_FILE, 'w') as f:
json.dump({"GOOGLE_API_KEY": api_key}, f)
print("API Key saved to config.ini.")
return api_key
# --- Jikan API Interaction with Rate Limiting ---
def jikan_api_request(endpoint, params=None):
"""
Makes a rate-limited request to the Jikan API.
Waits if the request limit has been reached in the last second.
"""
global REQUEST_TIMESTAMPS
while True:
now = time.time()
# Remove timestamps older than the time window
while REQUEST_TIMESTAMPS and REQUEST_TIMESTAMPS[0] < now - TIME_WINDOW:
REQUEST_TIMESTAMPS.popleft()
if len(REQUEST_TIMESTAMPS) < REQUEST_LIMIT:
break
# Calculate sleep time to respect the rate limit
sleep_time = (REQUEST_TIMESTAMPS[0] + TIME_WINDOW) - now + 0.05 # small buffer
print(f"Jikan rate limit reached. Waiting for {sleep_time:.2f} seconds...")
time.sleep(sleep_time)
try:
REQUEST_TIMESTAMPS.append(time.time())
print(f"Making Jikan request to: {JIKAN_API_BASE_URL}{endpoint}")
response = requests.get(f"{JIKAN_API_BASE_URL}{endpoint}", params=params)
response.raise_for_status() # Raises an HTTPError for bad responses (4xx or 5xx)
return response.json()
except requests.exceptions.RequestException as e:
print(f"\n--- Jikan API Error --- \n{e}\n------------------")
return None
# --- Helper Functions ---
def clear_screen():
"""Clears the console screen."""
os.system('cls' if os.name == 'nt' else 'clear')
def get_choice(max_choice, allow_back=True):
"""Gets and validates a user's integer choice."""
while True:
try:
prompt = "> "
choice = input(prompt)
if allow_back and choice.lower() == 'b':
return 'b'
choice = int(choice)
if 1 <= choice <= max_choice:
return choice
else:
print(f"Invalid choice. Please enter a number between 1 and {max_choice}.")
except ValueError:
print("Invalid input. Please enter a number.")
def format_anime_data(anime_obj):
"""Formats Jikan anime data into the structure needed for content.json."""
return {
"id": anime_obj['mal_id'],
"name": anime_obj.get('title_english') or anime_obj.get('title'),
"image": anime_obj['images']['jpg']['large_image_url']
}
def search_and_select_anime():
"""Prompts user to search for an anime, displays results, and returns the selected one."""
query = input("Enter search term (or 'b' to go back): ")
if query.lower() == 'b':
return None
results = jikan_api_request("/anime", params={"q": query, "limit": 10})
if not results or not results.get('data'):
print("No results found.")
input("Press Enter to continue...")
return None
clear_screen()
print(f"--- Search Results for '{query}' ---")
for i, anime in enumerate(results['data'], 1):
print(f"[{i}] {anime.get('title_english') or anime.get('title')} ({anime.get('type', 'N/A')}, {anime.get('year', 'N/A')})")
print("\n[b] Back to previous menu")
print("\nSelect an anime to add:")
choice = get_choice(len(results['data']))
if choice == 'b':
return None
return results['data'][choice - 1]
# --- Management Logic ---
def manage_spotlight(data):
"""Handles logic for managing the spotlight section."""
while True:
clear_screen()
print("--- Manage Spotlight Section ---")
if not data['spotlight']:
print("Spotlight is currently empty.")
else:
for i, item in enumerate(data['spotlight'], 1):
print(f"[{i}] {item['name']} (ID: {item['id']})")
print("\nOptions:")
print("[1] Add an anime to Spotlight")
print("[2] Remove an anime from Spotlight")
print("[b] Back to Main Menu")
choice = input("> ").lower()
if choice == '1':
anime_obj = search_and_select_anime()
if anime_obj:
if any(item['id'] == anime_obj['mal_id'] for item in data['spotlight']):
print(f"'{anime_obj['title']}' is already in the spotlight.")
else:
formatted = format_anime_data(anime_obj)
data['spotlight'].append(formatted)
print(f"Added '{formatted['name']}' to spotlight.")
input("Press Enter to continue...")
elif choice == '2':
if not data['spotlight']:
print("Nothing to remove.")
input("Press Enter to continue...")
continue
print("Enter the number of the anime to remove (or 'b' to cancel):")
remove_choice = get_choice(len(data['spotlight']))
if remove_choice != 'b':
removed = data['spotlight'].pop(remove_choice - 1)
print(f"Removed '{removed['name']}' from spotlight.")
input("Press Enter to continue...")
elif choice == 'b':
return
def manage_sections(data):
"""Handles logic for managing horizontal sections."""
while True:
clear_screen()
print("--- Manage Horizontal Sections ---")
if not data['sections']:
print("No sections created yet.")
else:
for i, section in enumerate(data['sections'], 1):
print(f"[{i}] {section['title']} ({len(section['items'])} items)")
print("\nOptions:")
print("[1] Create a new section")
print("[2] Edit an existing section")
print("[3] Delete a section")
print("[b] Back to Main Menu")
choice = input("> ").lower()
if choice == '1':
title = input("Enter title for new section: ")
data['sections'].append({"title": title, "items": []})
print(f"Section '{title}' created.")
input("Press Enter...")
elif choice == '2':
if not data['sections']:
print("No sections to edit.")
input("Press Enter...")
continue
for i, section in enumerate(data['sections'], 1):
print(f"[{i}] {section['title']} ({len(section['items'])} items)")
print("Select a section to edit:")
edit_choice = get_choice(len(data['sections']))
if edit_choice != 'b':
edit_section_menu(data['sections'][edit_choice - 1])
elif choice == '3':
if not data['sections']:
print("No sections to delete.")
input("Press Enter...")
continue
print("Select a section to delete:")
delete_choice = get_choice(len(data['sections']))
if delete_choice != 'b':
removed = data['sections'].pop(delete_choice - 1)
print(f"Deleted section '{removed['title']}'.")
input("Press Enter...")
elif choice == 'b':
return
def edit_section_menu(section):
"""Menu for editing a specific section."""
while True:
clear_screen()
print(f"--- Editing Section: {section['title']} ---")
if not section['items']:
print("This section is empty.")
else:
for i, item in enumerate(section['items'], 1):
print(f" [{i}] {item['name']} (ID: {item['id']})")
print("\nOptions:")
print("[1] Add an anime to this section (Manual Search)")
print("[2] Remove an anime from this section")
print("[3] Auto-populate this section (from Jikan)")
print("[4] Generate content with AI")
print("[5] Rename this section")
print("[b] Back to Sections Menu")
choice = input("> ").lower()
if choice == '1':
anime_obj = search_and_select_anime()
if anime_obj:
if any(item['id'] == anime_obj['mal_id'] for item in section['items']):
print(f"'{anime_obj['title']}' is already in this section.")
else:
formatted = format_anime_data(anime_obj)
section['items'].append(formatted)
print(f"Added '{formatted['name']}' to '{section['title']}'.")
input("Press Enter...")
elif choice == '2':
if not section['items']:
print("Nothing to remove.")
input("Press Enter...")
continue
print("Enter the number of the anime to remove:")
remove_choice = get_choice(len(section['items']))
if remove_choice != 'b':
removed = section['items'].pop(remove_choice - 1)
print(f"Removed '{removed['name']}' from '{section['title']}'.")
input("Press Enter...")
elif choice == '3':
auto_populate_section(section)
elif choice == '4':
generate_with_ai(section)
elif choice == '5':
new_title = input(f"Enter new title for '{section['title']}': ")
section['title'] = new_title
print("Section renamed.")
input("Press Enter...")
elif choice == 'b':
return
def auto_populate_section(section):
"""Automatically populates a section from a Jikan endpoint."""
clear_screen()
print(f"--- Auto-Populate Section: {section['title']} ---")
print("Select a category to populate from:")
print("[1] Top Anime by Popularity")
print("[2] Upcoming Season")
print("[3] Top Airing Anime")
print("[b] Cancel")
choice = get_choice(3)
if choice == 'b':
return
endpoint_map = {
1: ("/top/anime", {"filter": "bypopularity", "limit": 15}),
2: ("/seasons/upcoming", {"limit": 15}),
3: ("/top/anime", {"filter": "airing", "limit": 15})
}
endpoint, params = endpoint_map[choice]
results = jikan_api_request(endpoint, params=params)
if not results or not results.get('data'):
print("Could not fetch data for this category.")
input("Press Enter...")
return
added_count = 0
skipped_count = 0
for anime_obj in results['data']:
if not any(item['id'] == anime_obj['mal_id'] for item in section['items']):
section['items'].append(format_anime_data(anime_obj))
added_count += 1
else:
skipped_count += 1
print(f"Added {added_count} new items and skipped {skipped_count} duplicates in '{section['title']}'.")
input("Press Enter...")
def generate_with_ai(section):
"""Generates content for a section using Google's Generative AI."""
clear_screen()
print(f"--- AI Content Generation for: {section['title']} ---")
# 1. Get and configure API Key
try:
api_key = get_api_key()
genai.configure(api_key=api_key)
model = genai.GenerativeModel('gemini-1.5-flash')
except Exception as e:
print(f"An error occurred while configuring the AI model: {e}")
input("Press Enter to return.")
return
# 2. Get user prompt
print("Describe the kind of anime you want to find.")
print("Examples: 'top 10 classic sci-fi anime', 'underrated shows with great world-building', 'anime for beginners'")
user_prompt = input("\nEnter your prompt: ")
if not user_prompt:
return
# 3. Query the AI model with improved prompt
print("\nAsking the AI for suggestions... this may take a moment.")
full_prompt = f"""List exactly 10 anime that fit this description: '{user_prompt}'.
IMPORTANT: Follow this exact format for your response:
- Return ONLY the anime titles
- One title per line
- No numbers, bullets, dashes, or prefixes
- No descriptions or explanations
- No extra text before or after the list
- Use the most commonly known English or romanized title
Example format:
Attack on Titan
Death Note
Spirited Away
Your response for '{user_prompt}':"""
try:
response = model.generate_content(full_prompt)
if not response.text:
print("The AI returned an empty response. Please try again.")
input("Press Enter to return.")
return
# Clean and parse the response more robustly
ai_suggestions = []
lines = response.text.strip().split('\n')
for line in lines:
# Clean each line of common prefixes and formatting
cleaned = line.strip()
# Remove common prefixes like "1.", "- ", "• ", etc.
cleaned = re.sub(r'^[\d\.\-\\*\s]+', '', cleaned)
cleaned = cleaned.strip()
if cleaned and len(cleaned) > 1: # Ensure it's not just whitespace or single character
ai_suggestions.append(cleaned)
# Limit to 10 suggestions max
ai_suggestions = ai_suggestions[:10]
except Exception as e:
print(f"An error occurred while communicating with the AI: {e}")
input("Press Enter to return.")
return
if not ai_suggestions:
print("The AI didn't return any valid suggestions. Try a different prompt.")
input("Press Enter to return.")
return
print(f"\nAI suggested {len(ai_suggestions)} anime titles.")
# 4. Process suggestions: Search Jikan and get user confirmation
print("\n--- Confirm AI Suggestions ---")
print("For each suggestion, I will find the closest match on MyAnimeList.")
print("Please confirm if the match is correct.")
confirmed_anime = []
for i, suggestion in enumerate(ai_suggestions, 1):
print(f"\n[{i}/{len(ai_suggestions)}] Searching for: '{suggestion}'...")
results = jikan_api_request("/anime", params={"q": suggestion, "limit": 3})
if not results or not results.get('data'):
print(f"--> Could not find any match for '{suggestion}'.")
continue
# Show top match but also alternatives if the first doesn't seem right
match = results['data'][0]
title = match.get('title_english') or match.get('title')
print(f"--> Best match: '{title}' ({match.get('type', 'N/A')}, {match.get('year', 'N/A')})")
# Show alternatives if available
if len(results['data']) > 1:
print(" Alternatives:")
for j, alt in enumerate(results['data'][1:3], 2):
alt_title = alt.get('title_english') or alt.get('title')
print(f" [{j}] {alt_title} ({alt.get('type', 'N/A')}, {alt.get('year', 'N/A')})")
while True:
if len(results['data']) > 1:
choice = input(" Choose: [1] Use best match, [2-3] Use alternative, [s] Skip, [Enter] Use best match: ").strip().lower()
else:
choice = input(" [Enter] Add this anime, [s] Skip: ").strip().lower()
if choice == '' or choice == '1':
selected_match = results['data'][0]
break
elif choice == 's':
selected_match = None
break
elif choice in ['2', '3'] and len(results['data']) > int(choice) - 1:
selected_match = results['data'][int(choice) - 1]
break
else:
print(" Invalid choice. Please try again.")
if selected_match:
formatted = format_anime_data(selected_match)
# Avoid adding duplicates
if any(a['id'] == formatted['id'] for a in confirmed_anime):
print(f"--> Already added '{formatted['name']}'. Skipping.")
else:
confirmed_anime.append(formatted)
print(f"--> Added '{formatted['name']}' to the list.")
else:
print(f"--> Skipped '{suggestion}'.")
# 5. Final review and add to section
if not confirmed_anime:
print("\nNo new anime were confirmed. Returning to menu.")
input("Press Enter...")
return
clear_screen()
print("--- Final Review ---")
print("The following new anime will be added to the section:")
for item in confirmed_anime:
print(f"- {item['name']}")
final_confirm = input("\nAdd these items to the section? [Y/n]: ").lower()
if final_confirm == '' or final_confirm == 'y':
added_count = 0
skipped_count = 0
for anime in confirmed_anime:
if not any(item['id'] == anime['id'] for item in section['items']):
section['items'].append(anime)
added_count += 1
else:
skipped_count += 1
print(f"\nSuccessfully added {added_count} new anime.")
if skipped_count > 0:
print(f"Skipped {skipped_count} anime that were already in the section.")
else:
print("Operation cancelled. No changes were made.")
input("Press Enter to continue...")
# --- Main Application ---
def main():
"""Main function to run the content manager."""
data = None
# Check if a content file exists and prompt the user.
if os.path.exists(CONTENT_FILE):
clear_screen()
print("--- Welcome Back ---")
print(f"Found existing content file: '{CONTENT_FILE}'")
print("\nWhat would you like to do?")
print("[1] Load the existing content")
print("[2] Start from scratch (Warning: saving will overwrite the old file)")
while data is None:
choice = input("> ")
if choice == '1':
try:
with open(CONTENT_FILE, 'r') as f:
data = json.load(f)
# Ensure the basic structure exists, in case the file is malformed
if 'spotlight' not in data: data['spotlight'] = []
if 'sections' not in data: data['sections'] = []
print("Content loaded successfully.")
except (json.JSONDecodeError, FileNotFoundError):
print(f"Error: Could not read or parse '{CONTENT_FILE}'. Starting from scratch.")
data = {"spotlight": [], "sections": []}
elif choice == '2':
print("Starting with a blank slate.")
data = {"spotlight": [], "sections": []}
else:
print("Invalid choice. Please enter 1 or 2.")
input("Press Enter to continue...")
else:
# If no content file exists, start from scratch automatically.
print(f"No '{CONTENT_FILE}' found. Starting with a blank slate.")
data = {"spotlight": [], "sections": []}
input("Press Enter to continue...")
while True:
clear_screen()
print("--- Anime Content Manager ---")
print(" (with AI-Powered Suggestions)")
print("\nSelect an option:")
print("[1] Manage Spotlight Section")
print("[2] Manage Horizontal Sections")
print("[3] Save and Exit")
print("[4] Exit Without Saving")
choice = input("> ")
if choice == '1':
manage_spotlight(data)
elif choice == '2':
manage_sections(data)
elif choice == '3':
with open(CONTENT_FILE, 'w') as f:
json.dump(data, f, indent=4)
print(f"Content saved to {CONTENT_FILE}.")
break
elif choice == '4':
print("Exiting without saving changes.")
break
else:
print("Invalid option. Please try again.")
input("Press Enter to continue...")
if __name__ == "__main__":
main()

2628
animex/index.html Normal file

File diff suppressed because it is too large Load Diff

458
animex/intro.html Normal file
View File

@@ -0,0 +1,458 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0, user-scalable=no, viewport-fit=cover">
<title>Welcome to Animex</title>
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700;800&display=swap" rel="stylesheet">
<style>
:root {
/* Maintained the original brand color as requested */
--brand-primary: #FF9500;
--brand-primary-dark: #E6850E;
/* New theme colors inspired by the image */
--bg-main: #0F1014;
--bg-overlay: rgba(15, 16, 20, 0.2);
--bg-button-secondary: rgba(45, 48, 58, 0.7);
--bg-button-secondary-hover: rgba(55, 58, 68, 0.9);
--text-primary: #FFFFFF;
--text-secondary: #A0AEC0;
--text-tertiary: #718096;
--border-color: rgba(255, 255, 255, 0.1);
--shadow-color: rgba(0, 0, 0, 0.5);
--shadow-brand: rgba(255, 149, 0, 0.3);
/* UI Styles */
--font-family: 'Inter', -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
--radius-pill: 50px;
--radius-m: 16px;
--transition-speed: 0.4s;
}
html, body {
height: 100%;
margin: 0;
padding: 0;
font-family: var(--font-family);
color: var(--text-primary);
overflow: hidden;
background-color: var(--bg-main);
-webkit-text-size-adjust: 100%;
-webkit-touch-callout: none;
-webkit-user-select: none;
user-select: none;
}
body {
min-height: 100vh;
min-height: -webkit-fill-available;
}
/* Fullscreen background inspired by the image */
.background-container {
position: fixed;
top: 0;
left: 0;
width: 100%;
height: 100%;
background-size: cover;
background-position: center;
z-index: 0;
filter: blur(7px);
transform: scale(1.1);
opacity: 0.7;
}
/* Dark overlay to make text readable */
.background-overlay {
position: fixed;
top: 0;
left: 0;
width: 100%;
height: 100%;
background: linear-gradient(180deg, rgba(15, 16, 20, 0.0) 0%, var(--bg-main) 100%);
z-index: 1;
}
/* Main container now organizes content and nav vertically */
.onboarding-container {
display: flex;
flex-direction: column;
justify-content: space-between;
min-height: 100vh;
min-height: -webkit-fill-available;
width: 100%;
position: relative;
z-index: 2;
box-sizing: border-box;
padding: 40px 24px calc(24px + env(safe-area-inset-bottom)) 24px;
text-align: center;
}
/* Removed the card style, content now lives on the main view */
#onboarding-content {
flex-grow: 1;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 16px;
transition: opacity var(--transition-speed) ease, transform var(--transition-speed) ease;
padding: 0;
margin-top: 5vh; /* Pushes content down slightly from the top */
}
#onboarding-content.is-changing {
opacity: 0;
transform: translateY(20px);
}
.content-icon {
color: var(--brand-primary);
margin-bottom: 24px;
transition: opacity var(--transition-speed) ease, transform var(--transition-speed) ease;
}
.content-icon svg, .content-icon i {
width: 150px;
height: 150px;
filter: drop-shadow(0 8px 20px var(--shadow-brand));
}
.content-title {
font-size: 2.5rem; /* Larger, bolder title */
font-weight: 800;
color: var(--text-primary);
margin: 0;
letter-spacing: -0.03em;
line-height: 1.1;
max-width: 400px;
}
.content-desc {
font-size: 1rem;
font-weight: 400;
line-height: 1.6;
color: var(--text-secondary);
margin: 0;
max-width: 380px;
}
/* Navigation footer at the bottom */
#onboarding-nav {
display: flex;
flex-direction: column;
gap: 16px;
width: 100%;
max-width: 420px;
margin: 0 auto;
}
.progress-container {
display: flex;
align-items: center;
gap: 12px;
}
.progress-bar {
flex: 1;
height: 4px;
background: rgba(255, 255, 255, 0.1);
border-radius: 4px;
overflow: hidden;
}
.progress-bar-inner {
height: 100%;
width: 0%;
background: var(--brand-primary);
border-radius: 4px;
transition: width var(--transition-speed) ease;
}
.progress-text {
font-size: 0.9rem;
color: var(--text-tertiary);
font-weight: 500;
}
.nav-buttons {
display: flex;
gap: 12px;
align-items: center;
}
/* Restyled buttons to match new theme */
.btn {
font-family: var(--font-family);
font-size: 1rem;
font-weight: 600;
padding: 16px 24px;
border-radius: var(--radius-pill);
border: none;
cursor: pointer;
transition: all var(--transition-speed) ease;
text-decoration: none;
display: inline-flex;
align-items: center;
justify-content: center;
min-height: 54px;
-webkit-tap-highlight-color: transparent;
}
.btn-primary {
background: var(--brand-primary);
color: var(--bg-main);
font-weight: 700;
box-shadow: 0 8px 24px var(--shadow-brand);
flex: 1;
}
.btn-primary:hover {
transform: translateY(-2px) scale(1.02);
box-shadow: 0 12px 32px var(--shadow-brand);
}
.btn-secondary {
background: var(--bg-button-secondary);
color: var(--text-primary);
border: 1px solid var(--border-color);
}
.btn-secondary:hover {
background: var(--bg-button-secondary-hover);
border-color: rgba(255, 255, 255, 0.2);
}
.btn-tertiary {
background: transparent;
color: var(--text-secondary);
font-weight: 500;
}
.btn-tertiary:hover {
color: var(--text-primary);
}
#back-btn, #next-btn, #finish-btn, #skip-btn {
display: none;
}
/* Specific styles for PWA/Learn More sections */
.pwa-guide, .learn-more-btn {
margin-top: 24px;
background: rgba(255, 255, 255, 0.05);
border: 1px solid var(--border-color);
border-radius: var(--radius-m);
padding: 20px;
max-width: 380px;
}
.pwa-guide li { list-style: none; display: flex; align-items: center; gap: 16px; margin-bottom: 16px; padding: 0; }
.pwa-guide li:last-child { margin-bottom: 0; }
.pwa-guide-icon { background: var(--brand-primary); color: var(--bg-main); border-radius: 12px; width: 40px; height: 40px; display: flex; align-items: center; justify-content: center; flex-shrink: 0; font-size: 18px; box-shadow: 0 4px 12px var(--shadow-brand); }
.learn-more-btn { color: var(--brand-primary); }
.learn-more-btn:hover { background: rgba(255, 149, 0, 0.2); border-color: rgba(255, 149, 0, 0.5); }
/* Modal styles remain similar but use new variables for consistency */
#learn-more-modal { position: fixed; z-index: 9999; top: 0; left: 0; right: 0; bottom: 0; display: flex; align-items: center; justify-content: center; padding: 20px; }
.learn-modal-backdrop { position: absolute; top: 0; left: 0; right: 0; bottom: 0; background: rgba(0, 0, 0, 0.8); backdrop-filter: blur(8px); z-index: 0; }
.learn-modal-card { position: relative; background: #1a1b20; backdrop-filter: blur(20px); border: 1px solid var(--border-color); border-radius: var(--radius-m); box-shadow: 0 20px 60px var(--shadow-color); padding: 32px 24px 24px; max-width: 480px; width: 100%; z-index: 1; color: var(--text-primary); }
.learn-modal-card h2 { font-size: 1.5rem; font-weight: 700; margin: 0 0 24px 0; color: var(--text-primary); }
.learn-modal-close { position: absolute; top: 16px; right: 18px; background: rgba(255, 255, 255, 0.1); border: none; border-radius: 50%; width: 32px; height: 32px; font-size: 18px; color: var(--text-tertiary); cursor: pointer; transition: all 0.3s ease; display: flex; align-items: center; justify-content: center; }
.learn-modal-close:hover { background: rgba(255, 149, 0, 0.2); color: var(--brand-primary); }
@media (max-width: 480px) {
.content-title {
font-size: 2rem;
}
.content-icon svg {
width: 50px;
height: 50px;
}
}
</style>
</head>
<body>
<!-- Simplified background structure -->
<div id="background-container" class="background-container"></div>
<div class="background-overlay"></div>
<div class="onboarding-container">
<!-- Content and navigation are now direct children of the main container -->
<main id="onboarding-content" aria-live="polite">
<!-- Content injected by JavaScript -->
</main>
<footer id="onboarding-nav">
<div class="progress-container">
<div class="progress-bar">
<div class="progress-bar-inner" id="progress-bar-inner"></div>
</div>
<div class="progress-text" id="progress-text">1/5</div>
</div>
<div class="nav-buttons">
<button class="btn btn-secondary" id="back-btn">Back</button>
<button class="btn btn-tertiary" id="skip-btn">Skip</button>
<button class="btn btn-primary" id="next-btn">Next</button>
<button class="btn btn-primary" id="finish-btn">Get Started</button>
</div>
</footer>
</div>
<script src="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.5.1/js/all.min.js"></script>
<script>
(function() {
// The JS content remains identical in terms of data and logic
const introPages = [
{
icon: `<svg width="100" height="100" viewBox="0 0 150 150" fill="none" xmlns="http://www.w3.org/2000/svg"><rect x="25" y="21" width="99.3511" height="13.6477" fill="currentColor"/><rect x="25" y="43.8692" width="99.3511" height="13.6477" fill="currentColor"/><path d="M119.419 129.813H103.179L86.6541 30.5903H103.179L119.419 129.813Z" fill="currentColor"/><path d="M31.6938 129.813H47.9337L64.4585 30.5903H47.9337L31.6938 129.813Z" fill="currentColor"/><rect x="64.4585" y="107.998" width="20.7862" height="21.8153" fill="currentColor"/><ellipse cx="74.8516" cy="107.796" rx="10.3931" ry="11.9176" fill="currentColor"/></svg>`,
title: 'Welcome to Animex',
desc: 'Your premium platform for discovering, watching, and reading the best anime and manga.',
backgroundUrl: 'Resources/Images/Launch_Screen.png'
},
{
icon: `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 640 512"><path fill="currentColor" d="M64 64l0 288 512 0 0-288L64 64zM0 64C0 28.7 28.7 0 64 0L576 0c35.3 0 64 28.7 64 64l0 288c0 35.3-28.7 64-64 64L64 416c-35.3 0-64-28.7-64-64L0 64zM128 448l384 0c17.7 0 32 14.3 32 32s-14.3 32-32 32l-384 0c-17.7 0-32-14.3-32-32s14.3-32 32-32z"/></svg>`,
title: 'Endless Content',
desc: 'Thousands of anime episodes and manga chapters at your fingertips.',
backgroundUrl: 'Resources/Images/Launch_Screen.png'
},
{
icon: `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 576 512"><path fill="currentColor" d="M272 416c17.7 0 32-14.3 32-32s-14.3-32-32-32l-112 0c-17.7 0-32-14.3-32-32l0-128 32 0c12.9 0 24.6-7.8 29.6-19.8s2.2-25.7-6.9-34.9l-64-64c-12.5-12.5-32.8-12.5-45.3 0l-64 64c-9.2 9.2-11.9 22.9-6.9 34.9s16.6 19.8 29.6 19.8l32 0 0 128c0 53 43 96 96 96l112 0zM304 96c-17.7 0-32 14.3-32 32s14.3 32 32 32l112 0c17.7 0 32 14.3 32 32l0 128-32 0c-12.9 0-24.6 7.8-29.6 19.8s-2.2 25.7 6.9 34.9l64 64c12.5 12.5 32.8 12.5 45.3 0l64-64c9.2 9.2 11.9-22.9 6.9-34.9s-16.6-19.8-29.6-19.8l-32 0 0-128c0-53-43-96-96-96L304 96z"/></svg>`,
title: 'Seamless Experience',
desc: 'Pick up where you left off across all your devices.',
backgroundUrl: 'Resources/Images/Launch_Screen.png'
},
{
icon: `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 640 512"><path fill="currentColor" d="M144 480C64.5 480 0 415.5 0 336c0-62.8 40.2-116.2 96.2-135.9c-.1-2.7-.2-5.4-.2-8.1c0-88.4 71.6-160 160-160c59.3 0 111 32.2 138.7 80.2C409.9 102 428.3 96 448 96c53 0 96 43 96 96c0 12.2-2.3 23.8-6.4 34.6C596 238.4 640 290.1 640 352c0 70.7-57.3 128-128 128l-368 0zm79-167l80 80c9.4 9.4 24.6 9.4 33.9 0l80-80c9.4-9.4 9.4-24.6 0-33.9s-24.6-9.4-33.9 0l-39 39L344 184c0-13.3-10.7-24-24-24s-24 10.7-24 24l0 134.1-39-39c-9.4-9.4-24.6-9.4-33.9 0s-9.4 24.6 0 33.9z"/></svg>`,
title: 'Offline Downloads',
desc: 'Download your favorite episodes and chapters to enjoy them anywhere, anytime.',
learnMore: true,
backgroundUrl: 'Resources/Images/Launch_Screen.png'
},
{
pwa: true,
icon: `<i class="fa-solid fa-clapperboard"></i>`,
title: 'Install the App',
desc: 'Add Animex to your home screen for the best experience.',
pwaGuide: `
<ul class="pwa-guide">
<li><span class="pwa-guide-icon"><i class="fas fa-share-alt"></i></span> <span>Tap the <strong>Share</strong> button</span></li>
<li><span class="pwa-guide-icon"><i class="fas fa-plus-square"></i></span> <span>Select <strong>'Add to Home Screen'</strong></span></li>
</ul>
`,
backgroundUrl: 'Resources/Images/Launch_Screen.png'
}
];
const contentEl = document.getElementById('onboarding-content');
const navEl = document.getElementById('onboarding-nav');
const progressBarInnerEl = document.getElementById('progress-bar-inner');
const progressTextEl = document.getElementById('progress-text');
const backBtn = document.getElementById('back-btn');
const nextBtn = document.getElementById('next-btn');
const finishBtn = document.getElementById('finish-btn');
const skipBtn = document.getElementById('skip-btn');
const bgContainer = document.getElementById('background-container');
let currentPage = 0;
let availablePages = [];
function finishOnboarding() {
setTimeout(() => window.parent.showToast('Welcome to Animex! 🎉'), 600);
}
function updateNavigation() {
const total = availablePages.length;
const isFirstPage = currentPage === 0;
const isLastPage = currentPage === total - 1;
progressBarInnerEl.style.width = `${((currentPage + 1) / total) * 100}%`;
progressTextEl.textContent = `${currentPage + 1}/${total}`;
backBtn.style.display = isFirstPage ? 'none' : 'flex';
skipBtn.style.display = isLastPage ? 'none' : 'flex';
nextBtn.style.display = isLastPage ? 'none' : 'flex';
finishBtn.style.display = isLastPage ? 'flex' : 'none';
}
function updateContent() {
const pageData = availablePages[currentPage];
contentEl.classList.add('is-changing');
// Use a placeholder background. In a real app, you might use one per slide.
// I'm using the first slide's image for a consistent look.
const bgUrl = availablePages[0].backgroundUrl;
bgContainer.style.backgroundImage = `url("${bgUrl}")`;
setTimeout(() => {
let iconHtml = `<div class="content-icon">${pageData.icon}</div>`;
let contentHtml = `
${iconHtml}
<h1 class="content-title">${pageData.title}</h1>
<p class="content-desc">${pageData.desc}</p>
`;
if (pageData.pwaGuide) contentHtml += pageData.pwaGuide;
if (pageData.learnMore) contentHtml += `<button class="btn learn-more-btn" id="learn-more-btn">Learn More</button>`;
contentEl.innerHTML = contentHtml;
requestAnimationFrame(() => contentEl.classList.remove('is-changing'));
if (pageData.learnMore) {
document.getElementById('learn-more-btn').onclick = showLearnMoreModal;
}
}, 200);
}
function handleNavClick(e) {
const action = e.target.id;
switch (action) {
case 'next-btn':
if (currentPage < availablePages.length - 1) {
currentPage++;
updateContent();
updateNavigation();
}
break;
case 'back-btn':
if (currentPage > 0) {
currentPage--;
updateContent();
updateNavigation();
}
break;
case 'finish-btn': finishOnboarding(); break;
case 'skip-btn':
currentPage = availablePages.length - 1;
updateContent();
updateNavigation();
break;
}
}
// Modal function remains unchanged
function showLearnMoreModal() {
if (document.getElementById('learn-more-modal')) return;
const modal = document.createElement('div');
modal.id = 'learn-more-modal';
modal.innerHTML = `<div class="learn-modal-backdrop"></div> <div class="learn-modal-card"> <button class="learn-modal-close" id="close-learn-modal" title="Close">×</button> <h2>Organize Your Downloads</h2> <div class="folder-tree" style="font-size: 1rem; color: var(--text-secondary); text-align: left; line-height: 1.8; background: rgba(255, 255, 255, 0.02); border: 1px solid rgba(255, 255, 255, 0.08); border-radius: var(--radius-m); padding: 20px; margin-bottom: 20px;"> <div style="font-size: 1.1rem; color: var(--brand-primary); font-weight: 700;"><span style="margin-right: 8px; color: var(--brand-primary);"><i class='fas fa-folder'></i></span> <strong>Anime</strong> <span style="font-size: 0.9em; color: var(--text-tertiary); margin-left: 6px; font-weight: 400;">(Main Folder)</span></div><div style="margin-left: 24px; color: var(--text-secondary); font-weight: 600;"><span style="margin-right: 8px; color: var(--brand-primary);"><i class='fas fa-folder'></i></span> <strong>Attack on Titan</strong> <div style="margin-left: 32px; color: var(--text-tertiary); font-weight: 400; display: flex; align-items: center; gap: 6px;"><span style="color: var(--brand-primary); margin-right: 4px;"><i class='fas fa-play-circle'></i></span> Episode 1.mp4</div></div></div><div class="learn-modal-desc" style="padding-left: 0;"> <ul style="padding-left: 20px; margin: 0; color: var(--text-secondary); font-size: 1rem; line-height: 1.6;"> <li style="margin-bottom: 12px;"><strong>Step 1:</strong> Create a main folder on your device.</li><li style="margin-bottom: 12px;"><strong>Step 2:</strong> Add subfolders for each series.</li><li style="margin-bottom: 12px;"><strong>Step 3:</strong> Animex will automatically organize your library!</li></ul> </div></div>`;
document.body.appendChild(modal);
document.getElementById('close-learn-modal').onclick = () => modal.remove();
modal.onclick = (e) => { if (e.target.classList.contains('learn-modal-backdrop')) modal.remove(); };
}
function init() {
const isStandalone = window.matchMedia('(display-mode: standalone)').matches;
availablePages = isStandalone ? introPages.filter(p => !p.pwa) : [...introPages];
updateContent();
updateNavigation();
navEl.addEventListener('click', handleNavClick);
}
init();
})();
</script>
</body>
</html>

1515
animex/library.html Normal file

File diff suppressed because it is too large Load Diff

395
animex/lists.html Normal file
View File

@@ -0,0 +1,395 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Manage Lists</title>
<link rel="preconnect" href="https://fonts.googleapis.com" />
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
<link
href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&display=swap"
rel="stylesheet"
/>
<link
rel="stylesheet"
href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.5.1/css/all.min.css"
/>
<style>
:root {
--bg-color: #18181b;
--surface-color: #27272a;
--border-color: rgba(255, 255, 255, 0.1);
--text-primary: #f4f4f5;
--text-secondary: #a1a1aa;
--accent-color: #ff9500;
--accent-hover: #ffac33;
--danger-color: #ef4444;
--font-family: "Inter", sans-serif;
--radius: 8px;
}
* {
box-sizing: border-box;
}
body,
html {
margin: 0;
padding: 0;
font-family: var(--font-family);
background-color: var(--bg-color);
color: var(--text-primary);
height: 100%;
overflow: hidden;
}
.manager-container {
display: flex;
flex-direction: column;
height: 100%;
padding: 16px;
}
.manager-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 16px;
flex-shrink: 0;
}
.manager-title {
font-size: 1.5rem;
font-weight: 700;
}
.close-btn {
background: none;
border: none;
color: var(--text-secondary);
font-size: 1.5rem;
cursor: pointer;
transition: color 0.2s;
}
.close-btn:hover {
color: var(--text-primary);
}
.item-info {
background-color: var(--surface-color);
border: 1px solid var(--border-color);
padding: 12px;
border-radius: var(--radius);
margin-bottom: 16px;
font-size: 0.9rem;
color: var(--text-secondary);
flex-shrink: 0;
}
.item-info strong {
color: var(--text-primary);
font-weight: 600;
}
.lists-body {
flex-grow: 1;
overflow-y: auto;
margin-bottom: 16px;
}
.list-item {
display: flex;
justify-content: space-between;
align-items: center;
padding: 16px;
background-color: var(--surface-color);
border: 1px solid var(--border-color);
border-radius: var(--radius);
margin-bottom: 8px;
cursor: pointer;
transition: background-color 0.2s, border-color 0.2s;
}
.list-item:hover {
background-color: #3f3f46;
border-color: rgba(255, 255, 255, 0.2);
}
.list-item.in-list {
border-color: var(--accent-color);
background-color: rgba(255, 149, 0, 0.1);
}
.list-item.in-list .list-name::after {
content: " (Saved)";
color: var(--accent-color);
font-size: 0.8rem;
}
.list-name {
font-weight: 500;
}
.delete-list-btn {
background: none;
border: none;
color: var(--text-secondary);
font-size: 1rem;
cursor: pointer;
padding: 4px;
transition: color 0.2s;
}
.delete-list-btn:hover {
color: var(--danger-color);
}
.manager-footer {
flex-shrink: 0;
}
.new-list-form {
display: flex;
gap: 8px;
}
.new-list-input {
flex-grow: 1;
background-color: var(--surface-color);
border: 1px solid var(--border-color);
border-radius: var(--radius);
padding: 12px;
font-size: 1rem;
color: var(--text-primary);
outline: none;
transition: border-color 0.2s, box-shadow 0.2s;
}
.new-list-input:focus {
border-color: var(--accent-color);
box-shadow: 0 0 0 3px rgba(255, 149, 0, 0.3);
}
.add-list-btn {
background-color: var(--accent-color);
border: none;
color: #000;
padding: 0 20px;
font-weight: 600;
border-radius: var(--radius);
cursor: pointer;
transition: background-color 0.2s;
}
.add-list-btn:hover {
background-color: var(--accent-hover);
}
.placeholder {
text-align: center;
color: var(--text-secondary);
padding: 32px;
}
</style>
</head>
<body>
<div class="manager-container">
<header class="manager-header">
<h1 class="manager-title">Add to List</h1>
<button class="close-btn" id="close-btn" title="Close">&times;</button>
</header>
<div class="item-info" id="item-info">
Select a list to save the item to.
</div>
<div class="lists-body" id="lists-container">
<!-- List items will be generated here -->
<div class="placeholder" id="lists-placeholder">
Create your first list below.
</div>
</div>
<footer class="manager-footer">
<form id="new-list-form" class="new-list-form">
<input
type="text"
id="new-list-input"
class="new-list-input"
placeholder="Create a new list..."
required
/>
<button type="submit" class="add-list-btn">Create</button>
</form>
</footer>
</div>
<script>
document.addEventListener("DOMContentLoaded", () => {
const itemInfoEl = document.getElementById("item-info");
const listsContainer = document.getElementById("lists-container");
const listsPlaceholder = document.getElementById("lists-placeholder");
const newListForm = document.getElementById("new-list-form");
const newListInput = document.getElementById("new-list-input");
const closeBtn = document.getElementById("close-btn");
let currentItem = null;
let userLists = {};
const LISTS_STORAGE_KEY = "animex_lists_v1";
// --- DATA & STORAGE ---
function loadLists() {
try {
const storedLists = localStorage.getItem(LISTS_STORAGE_KEY);
userLists = storedLists ? JSON.parse(storedLists) : {};
} catch (e) {
console.error("Failed to parse user lists from localStorage", e);
userLists = {};
}
}
function saveLists() {
try {
localStorage.setItem(LISTS_STORAGE_KEY, JSON.stringify(userLists));
} catch (e) {
console.error("Failed to save user lists to localStorage", e);
}
}
// --- UI RENDERING ---
function renderLists() {
listsContainer.innerHTML = "";
const listNames = Object.keys(userLists);
if (listNames.length === 0) {
listsContainer.appendChild(listsPlaceholder);
listsPlaceholder.style.display = "block";
return;
}
listsPlaceholder.style.display = "none";
listNames
.sort((a, b) => a.localeCompare(b))
.forEach((listName) => {
const listItem = document.createElement("div");
listItem.className = "list-item";
listItem.dataset.listName = listName;
const nameSpan = document.createElement("span");
nameSpan.className = "list-name";
nameSpan.textContent = listName;
const deleteBtn = document.createElement("button");
deleteBtn.className = "delete-list-btn";
deleteBtn.innerHTML = '<i class="fas fa-trash-alt"></i>';
deleteBtn.title = `Delete "${listName}" list`;
listItem.appendChild(nameSpan);
listItem.appendChild(deleteBtn);
listsContainer.appendChild(listItem);
});
updateListSelection();
}
function updateItemInfo() {
if (!currentItem) {
itemInfoEl.innerHTML = "Select a list to save the item to.";
return;
}
const itemType =
currentItem.type === "anime" ? "Episode(s)" : "Chapter(s)";
const itemsDisplay =
currentItem.items.length > 3
? `${currentItem.items.slice(0, 3).join(", ")}...`
: currentItem.items.join(", ");
itemInfoEl.innerHTML = `Saving <strong>${currentItem.title}</strong> - ${itemType}: <strong>${itemsDisplay}</strong>`;
}
function updateListSelection() {
if (!currentItem) return;
document.querySelectorAll(".list-item").forEach((el) => {
const listName = el.dataset.listName;
const list = userLists[listName];
// Check if the series is in the list
const seriesEntry = list.find(
(entry) => entry[0] === currentItem.id
);
if (seriesEntry) {
el.classList.add("in-list");
} else {
el.classList.remove("in-list");
}
});
}
// --- LOGIC ---
function createList(listName) {
if (!listName || userLists.hasOwnProperty(listName)) {
window.parent.showToast("List name already exists or is invalid.");
return;
}
userLists[listName] = [];
saveLists();
renderLists();
}
function deleteList(listName) {
if (
!userLists.hasOwnProperty(listName) ||
!confirm(`Are you sure you want to delete the "${listName}" list?`)
) {
return;
}
delete userLists[listName];
saveLists();
renderLists();
}
function toggleItemInList(listName) {
if (!currentItem) return;
const list = userLists[listName];
let seriesEntry = list.find((entry) => entry[0] === currentItem.id);
if (seriesEntry) {
// Series exists, so remove it entirely for simplicity
userLists[listName] = list.filter(
(entry) => entry[0] !== currentItem.id
);
} else {
// Series doesn't exist, add it
const newEntry = [currentItem.id, currentItem.items.join(",")];
list.push(newEntry);
}
saveLists();
updateListSelection();
}
// --- EVENT LISTENERS ---
window.addEventListener("message", (event) => {
if (event.data && event.data.type === "manage-item") {
currentItem = event.data.data;
console.log("Received item to manage:", currentItem);
updateItemInfo();
updateListSelection();
}
});
closeBtn.addEventListener("click", () => {
window.parent.postMessage("close-list-manager", "*");
});
newListForm.addEventListener("submit", (e) => {
e.preventDefault();
const listName = newListInput.value.trim();
createList(listName);
newListInput.value = "";
});
listsContainer.addEventListener("click", (e) => {
const listItem = e.target.closest(".list-item");
if (!listItem) return;
const listName = listItem.dataset.listName;
if (e.target.closest(".delete-list-btn")) {
deleteList(listName);
} else {
toggleItemInList(listName);
}
});
// --- INITIALIZATION ---
function init() {
loadLists();
renderLists();
}
init();
});
</script>
</body>
</html>

456
animex/login.html Normal file
View File

@@ -0,0 +1,456 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta
name="viewport"
content="width=device-width, initial-scale=1.0, user-scalable=no, viewport-fit=cover"
/>
<title>Select Profile - Animex</title>
<link
href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&display=swap"
rel="stylesheet"
/>
<link
rel="stylesheet"
href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.5.1/css/all.min.css"
/>
<link rel="manifest" href="Resources/manifest.json/" />
<meta name="theme-color" content="#0b0b0b" />
<link rel="icon" href="/Resources/Images/icon-196.png" type="image/png" />
<!-- iOS support -->
<link rel="apple-touch-icon" href="/Resources/Images/icon-196.png" />
<style>
:root {
--background-primary: #121212;
--foreground-primary: #e6e6e6;
--foreground-secondary: #a0a0a0;
--background-modal: #252525;
--input-border-color: #3a3a3c;
--accent-primary: #ff9500;
--accent-primary-rgb: 255, 149, 0;
--destructive: #ff453a;
--radius-sm: 8px;
--radius-md: 16px;
}
body {
background-color: var(--background-primary);
color: var(--foreground-primary);
font-family: "Inter", sans-serif;
margin: 0;
display: flex;
justify-content: center;
align-items: center;
min-height: 100vh;
-webkit-font-smoothing: antialiased;
}
.profile-container {
text-align: center;
animation: fadeIn 0.5s ease-in-out;
}
@keyframes fadeIn {
from {
opacity: 0;
transform: translateY(20px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
.profile-container h1 {
font-size: 3rem;
font-weight: 700;
margin-bottom: 2rem;
}
.profile-grid {
display: flex;
flex-wrap: wrap;
justify-content: center;
gap: 2rem;
margin-bottom: 3rem;
}
.profile-item {
cursor: pointer;
text-align: center;
transition: transform 0.2s ease;
position: relative;
}
.profile-item:not(.add) img {
width: 150px;
height: 150px;
border-radius: var(--radius-md);
object-fit: cover;
margin-bottom: 1rem;
border: 4px solid transparent;
transition: all 0.2s ease;
}
.profile-item:not(.add):hover img {
transform: scale(1.05);
border-color: var(--accent-primary);
}
.profile-item .profile-name {
font-weight: 500;
font-size: 1.2rem;
color: var(--foreground-secondary);
transition: color 0.2s ease;
}
.profile-item:hover .profile-name {
color: var(--foreground-primary);
}
.profile-item.add .add-icon {
width: 150px;
height: 150px;
border-radius: var(--radius-md);
display: flex;
justify-content: center;
align-items: center;
font-size: 3rem;
color: var(--foreground-secondary);
background-color: #2a2a2a;
transition: all 0.2s ease;
margin-bottom: 1rem;
}
.profile-item.add:hover .add-icon {
background-color: var(--accent-primary);
color: white;
transform: scale(1.05);
}
.manage-icon {
position: absolute;
top: 55px;
left: 55px;
width: 40px;
height: 40px;
background: rgba(0, 0, 0, 0.6);
color: white;
border-radius: 50%;
display: none;
justify-content: center;
align-items: center;
font-size: 1.2rem;
}
.manage-mode .manage-icon {
display: flex;
}
.manage-mode .profile-item:hover img {
filter: brightness(0.7);
}
.manage-button {
background: none;
border: 1px solid var(--foreground-secondary);
color: var(--foreground-secondary);
padding: 0.75rem 2rem;
border-radius: var(--radius-sm);
font-size: 1rem;
cursor: pointer;
transition: all 0.2s ease;
}
.manage-button:hover {
border-color: var(--foreground-primary);
color: var(--foreground-primary);
background-color: #2a2a2a;
}
.modal-backdrop {
position: fixed;
inset: 0;
background-color: rgba(0, 0, 0, 0.7);
backdrop-filter: blur(8px);
display: none;
justify-content: center;
align-items: center;
z-index: 1000;
}
.modal-content {
background-color: var(--background-modal);
padding: 2rem;
border-radius: var(--radius-md);
width: 90%;
max-width: 400px;
}
.modal-content h2 {
margin-top: 0;
text-align: center;
}
.form-group {
margin-bottom: 1rem;
}
.form-group label {
display: block;
margin-bottom: 0.5rem;
}
.form-group input {
width: 100%;
padding: 0.75rem;
background-color: var(--background-primary);
border: 1px solid var(--input-border-color);
border-radius: var(--radius-sm);
color: var(--foreground-primary);
font-size: 1rem;
}
.modal-actions {
display: flex;
gap: 1rem;
margin-top: 2rem;
}
.primary-button {
flex-grow: 1;
padding: 0.75rem;
border: none;
border-radius: var(--radius-sm);
font-size: 1rem;
font-weight: 600;
cursor: pointer;
background-color: var(--accent-primary);
color: white;
}
.secondary-button {
flex-grow: 1;
background-color: #3a3a3c;
}
.delete-button {
background-color: var(--destructive);
}
</style>
</head>
<body>
<div class="profile-container" id="profileContainer">
<h1>Who's Watching?</h1>
<div class="profile-grid" id="profileGrid">
<!-- Profiles will be dynamically inserted here -->
</div>
<button class="manage-button" id="manageProfilesBtn">
Manage Profiles
</button>
</div>
<!-- MODAL for Profile Create/Edit -->
<div id="profileModal" class="modal-backdrop">
<div class="modal-content">
<h2 id="profileModalTitle">Create Profile</h2>
<form id="profileForm">
<div class="form-group">
<label for="profileNameInput">Name</label>
<input
type="text"
id="profileNameInput"
required
maxlength="20"
autocomplete="off"
/>
</div>
<div class="modal-actions">
<button
type="button"
id="deleteProfileBtn"
class="primary-button delete-button"
style="display: none"
>
Delete
</button>
<button
type="button"
id="closeProfileModalBtn"
class="primary-button secondary-button"
>
Cancel
</button>
<button type="submit" id="saveProfileBtn" class="primary-button">
Save
</button>
</div>
</form>
</div>
</div>
<script>
document.addEventListener("DOMContentLoaded", () => {
const profileContainer = document.getElementById("profileContainer");
const profileGrid = document.getElementById("profileGrid");
const manageProfilesBtn = document.getElementById("manageProfilesBtn");
const profileModal = document.getElementById("profileModal");
const profileForm = document.getElementById("profileForm");
const profileModalTitle = document.getElementById("profileModalTitle");
const profileNameInput = document.getElementById("profileNameInput");
const closeProfileModalBtn = document.getElementById(
"closeProfileModalBtn"
);
const saveProfileBtn = document.getElementById("saveProfileBtn");
const deleteProfileBtn = document.getElementById("deleteProfileBtn");
let profiles = [];
let isManageMode = false;
let profileToEdit = null;
async function initialize() {
await fetchAndRenderProfiles();
setupEventListeners();
}
async function fetchAndRenderProfiles() {
try {
const response = await fetch("/profiles");
if (!response.ok) throw new Error("Could not fetch profiles");
profiles = await response.json();
renderProfileGrid();
} catch (error) {
console.error(error);
profileGrid.innerHTML =
"<p>Could not load profiles. Is the server running?</p>";
}
}
function renderProfileGrid() {
profileGrid.innerHTML = "";
profiles.forEach((profile) => {
const item = createProfileElement(profile);
profileGrid.appendChild(item);
});
// Add the "Add Profile" button if not at max capacity
if (profiles.length < 10) {
const addProfileItem = createAddProfileElement();
profileGrid.appendChild(addProfileItem);
}
}
function createProfileElement(profile) {
const item = document.createElement("div");
item.className = "profile-item";
item.innerHTML = `
<img src="${profile.avatar_url}" alt="${profile.name}">
<div class="manage-icon"><i class="fas fa-pencil-alt"></i></div>
<div class="profile-name">${profile.name}</div>
`;
item.addEventListener("click", () => handleProfileClick(profile));
return item;
}
function createAddProfileElement() {
const item = document.createElement("div");
item.className = "profile-item add";
item.innerHTML = `
<div class="add-icon"><i class="fas fa-plus"></i></div>
<div class="profile-name">Add Profile</div>
`;
item.addEventListener("click", () => {
profileToEdit = null; // Ensure it's a create operation
openProfileModal();
});
return item;
}
function handleProfileClick(profile) {
if (isManageMode) {
profileToEdit = profile;
openProfileModal(profile);
} else {
loginWithProfile(profile);
}
}
function loginWithProfile(profile) {
localStorage.setItem("currentProfileId", profile.id);
// Redirect to the main app page or settings page
console.log(`Logged in as ${profile.name}`);
window.location.href = "index.html";
}
function toggleManageMode() {
isManageMode = !isManageMode;
profileContainer.classList.toggle("manage-mode", isManageMode);
manageProfilesBtn.textContent = isManageMode
? "Done"
: "Manage Profiles";
}
function openProfileModal(profile = null) {
profileForm.reset();
if (profile) {
profileModalTitle.textContent = "Edit Profile";
profileNameInput.value = profile.name;
deleteProfileBtn.style.display = "block";
} else {
profileModalTitle.textContent = "Create Profile";
deleteProfileBtn.style.display = "none";
}
profileModal.style.display = "flex";
profileNameInput.focus();
}
function closeProfileModal() {
profileModal.style.display = "none";
}
async function handleFormSubmit(e) {
e.preventDefault();
const name = profileNameInput.value.trim();
if (!name) return;
saveProfileBtn.disabled = true;
try {
if (profileToEdit) {
// Edit existing profile - change PATCH to PUT
const response = await fetch(`/profiles/${profileToEdit.id}`, {
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
id: profileToEdit.id,
name: name,
avatar_url: profileToEdit.avatar_url,
settings: profileToEdit.settings,
}),
});
if (!response.ok) throw new Error("Failed to update profile");
} else {
// Create new profile
const response = await fetch("/profiles", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ name }),
});
if (!response.ok) throw new Error("Failed to create profile");
}
closeProfileModal();
await fetchAndRenderProfiles();
} catch (error) {
console.error(error);
window.parent.showToast("An error occurred. Please try again.");
} finally {
saveProfileBtn.disabled = false;
}
}
async function handleDeleteProfile() {
if (
!profileToEdit ||
!confirm(`Delete profile "${profileToEdit.name}"?`)
) {
return;
}
try {
const response = await fetch(`/profiles/${profileToEdit.id}`, {
method: "DELETE",
});
if (!response.ok) throw new Error("Failed to delete profile");
closeProfileModal();
await fetchAndRenderProfiles();
} catch (error) {
console.error(error);
window.parent.showToast("Could not delete profile.");
}
}
function setupEventListeners() {
manageProfilesBtn.addEventListener("click", toggleManageMode);
profileForm.addEventListener("submit", handleFormSubmit);
closeProfileModalBtn.addEventListener("click", closeProfileModal);
deleteProfileBtn.addEventListener("click", handleDeleteProfile);
}
initialize();
});
</script>
</body>
</html>

38
animex/make.py Normal file
View File

@@ -0,0 +1,38 @@
import os
import re
# 1. Find all files, excluding .git and __pycache__
def get_all_files(base_dir):
file_list = []
for root, dirs, files in os.walk(base_dir):
# Exclude .git and __pycache__
dirs[:] = [d for d in dirs if d not in ['.git', '__pycache__']]
for file in files:
abs_path = os.path.join(root, file)
rel_path = os.path.relpath(abs_path, base_dir)
# Convert to web root-relative path
web_path = '/' + rel_path.replace('\\', '/').replace(' ', '%20')
file_list.append(web_path)
return sorted(file_list)
# 2. Update PRECACHE_URLS in sw.js
def update_precache_urls(sw_path, new_urls):
with open(sw_path, 'r', encoding='utf-8') as f:
content = f.read()
# Regex to find the PRECACHE_URLS array
pattern = re.compile(r'(const PRECACHE_URLS = \[)(.*?)(\];)', re.DOTALL)
# Format new URLs
url_lines = [f" '{url}'," for url in new_urls]
new_array = '\n' + '\n'.join(url_lines) + '\n'
new_content = pattern.sub(r"\\1" + new_array + r"\\3", content)
with open(sw_path, 'w', encoding='utf-8') as f:
f.write(new_content)
if __name__ == '__main__':
base_dir = os.path.dirname(os.path.abspath(__file__))
sw_path = os.path.join(base_dir, 'sw.js')
files = get_all_files(base_dir)
# Optionally filter out sw.js itself if you don't want to cache it
files = [f for f in files if not f.endswith('/sw.js')]
update_precache_urls(sw_path, files)
print(f'Updated PRECACHE_URLS in {sw_path} with {len(files)} files.')

917
animex/manga-info.html Normal file
View File

@@ -0,0 +1,917 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta
name="viewport"
content="width=device-width, initial-scale=1.0, user-scalable=no"
/>
<title>Manga Info</title>
<link rel="preconnect" href="https://fonts.googleapis.com" />
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
<link
href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700;800&display=swap"
rel="stylesheet"
/>
<link
rel="stylesheet"
href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.5.1/css/all.min.css"
/>
<style>
/* --- CSS VARIABLES & RESET --- */
:root {
--brand-accent: #ff9500; /* Orange from screenshot */
--brand-hover: #e08400;
--background-primary: #0a0a0a;
--background-secondary: #161616;
--background-tertiary: #202020;
--text-primary: #ffffff;
--text-secondary: #a1a1a1;
--text-muted: #666;
--border-color: #2a2a2a;
--success-color: #28a745;
--ongoing-color: #17a2b8;
--brand-glow: rgba(255, 149, 0, 0.3);
--shadow-color: rgba(0, 0, 0, 0.8);
--transition-duration: 0.2s;
--border-radius: 6px;
--container-width: 1400px;
}
*,
*::before,
*::after {
margin: 0;
padding: 0;
box-sizing: border-box;
}
body {
background-color: var(--background-primary);
color: var(--text-primary);
font-family: "Inter", sans-serif;
line-height: 1.5;
overflow-x: hidden;
}
/* --- HERO SECTION (DESKTOP LAYOUT LIKE SCREENSHOT) --- */
#hero-section {
position: relative;
width: 100%;
min-height: 45vh; /* Tall hero */
display: flex;
align-items: flex-end; /* Align content to bottom */
padding-bottom: 4rem;
background-size: cover;
background-position: center top;
background-repeat: no-repeat;
}
/* Dark Gradient Overlay */
.hero-overlay {
position: absolute;
inset: 0;
background: linear-gradient(
to right,
rgba(10, 10, 10, 0.9) 0%,
rgba(10, 10, 10, 0.7) 40%,
rgba(10, 10, 10, 0.4) 100%
),
linear-gradient(to top, #0a0a0a 10%, transparent 60%);
z-index: 1;
}
.hero-content {
position: relative;
z-index: 2;
width: 100%;
max-width: var(--container-width);
margin: 0 auto;
padding: 0 5%;
display: flex;
gap: 3rem;
align-items: flex-end;
}
/* Poster Image */
.poster-container {
flex-shrink: 0;
width: 240px;
aspect-ratio: 2 / 3;
border-radius: 8px;
box-shadow: 0 10px 30px rgba(0,0,0,0.5);
overflow: hidden;
margin-bottom: 20px; /* Slight lift */
}
.poster-image {
width: 100%;
height: 100%;
object-fit: cover;
}
/* Info Area */
.info-container {
flex-grow: 1;
display: flex;
flex-direction: column;
gap: 1.2rem;
padding-bottom: 20px;
}
.manga-title {
font-size: clamp(2.5rem, 5vw, 4rem);
font-weight: 800;
line-height: 1.1;
color: #fff;
text-shadow: 0 2px 10px rgba(0,0,0,0.5);
}
.meta-row {
display: flex;
flex-wrap: wrap;
gap: 12px;
align-items: center;
}
.meta-tag {
padding: 4px 8px;
background: rgba(255, 255, 255, 0.1);
border: 1px solid rgba(255, 255, 255, 0.2);
border-radius: 4px;
font-size: 0.85rem;
font-weight: 600;
color: #ddd;
}
.meta-tag.accent {
color: var(--brand-accent);
border-color: var(--brand-accent);
}
/* Action Buttons (The "Start EP 1" style) */
.action-row {
display: flex;
gap: 1rem;
margin-top: 0.5rem;
}
.btn-primary {
display: inline-flex;
align-items: center;
gap: 10px;
background-color: var(--brand-accent);
color: #000;
font-weight: 700;
font-size: 1rem;
padding: 14px 32px;
border-radius: 6px;
border: none;
cursor: pointer;
text-transform: uppercase;
transition: background-color var(--transition-duration);
}
.btn-primary:hover {
background-color: var(--brand-hover);
transform: translateY(-2px);
}
.btn-secondary {
display: inline-flex;
align-items: center;
gap: 10px;
background-color: rgba(255, 255, 255, 0.1);
color: #fff;
font-weight: 600;
font-size: 1rem;
padding: 14px 24px;
border-radius: 6px;
border: 1px solid rgba(255, 255, 255, 0.1);
cursor: pointer;
transition: background-color var(--transition-duration);
}
.btn-secondary:hover {
background-color: rgba(255, 255, 255, 0.2);
}
.synopsis-text {
font-size: 1rem;
line-height: 1.6;
color: #ccc;
max-width: 800px;
display: -webkit-box;
-webkit-line-clamp: 4;
-webkit-box-orient: vertical;
overflow: hidden;
}
/* --- MAIN CONTENT --- */
#main-content {
max-width: var(--container-width);
margin: 0 auto;
padding: 2rem 5%;
}
/* Tabs */
.tabs-container {
display: flex;
gap: 2rem;
margin-bottom: 2rem;
border-bottom: 1px solid var(--border-color);
}
.tab-btn {
padding: 1rem 0;
background: none;
border: none;
color: var(--text-secondary);
font-size: 1.1rem;
font-weight: 600;
cursor: pointer;
position: relative;
}
.tab-btn.active {
color: var(--text-primary);
}
.tab-btn.active::after {
content: "";
position: absolute;
bottom: -1px;
left: 0;
width: 100%;
height: 3px;
background-color: var(--brand-accent);
}
/* --- CHAPTER LIST --- */
.list-controls {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 1rem;
}
.pagination-controls {
display: flex;
gap: 5px;
}
.page-btn {
background: var(--background-secondary);
border: 1px solid var(--border-color);
color: var(--text-secondary);
padding: 6px 12px;
border-radius: 4px;
cursor: pointer;
}
.page-btn:hover:not(:disabled) {
background: var(--background-tertiary);
color: var(--text-primary);
}
.page-btn.active {
background: var(--brand-accent);
color: #000;
border-color: var(--brand-accent);
}
.page-btn:disabled {
opacity: 0.5;
cursor: not-allowed;
}
#chapter-list {
display: flex;
flex-direction: column;
gap: 10px;
list-style: none;
}
.chapter-item {
display: flex;
align-items: center;
background-color: var(--background-secondary);
border: 1px solid var(--border-color);
border-radius: 6px;
padding: 10px 15px;
cursor: pointer;
transition: transform 0.1s, border-color 0.1s;
}
.chapter-item:hover {
transform: scale(1.005);
border-color: var(--brand-accent);
background-color: #1e1e1e;
}
/* Status Indicators in List */
.chapter-status-indicator {
width: 4px;
height: 40px;
background: #333;
margin-right: 15px;
border-radius: 2px;
}
.chapter-item.status-read .chapter-status-indicator {
background: var(--success-color);
box-shadow: 0 0 5px var(--success-color);
}
.chapter-item.status-ongoing .chapter-status-indicator {
background: var(--ongoing-color);
box-shadow: 0 0 5px var(--ongoing-color);
}
.chapter-info {
flex-grow: 1;
}
.chapter-title {
font-weight: 600;
font-size: 1rem;
color: var(--text-primary);
display: block;
}
.chapter-meta {
font-size: 0.8rem;
color: var(--text-secondary);
margin-top: 4px;
}
.chapter-actions {
display: flex;
gap: 10px;
}
.icon-btn {
background: transparent;
border: none;
color: var(--text-secondary);
font-size: 1.2rem;
cursor: pointer;
padding: 8px;
transition: color 0.2s;
}
.icon-btn:hover {
color: var(--brand-accent);
}
.chapter-item.status-read .chapter-title {
color: var(--text-muted);
}
/* --- READER MODAL --- */
#reader-modal-container {
display: none;
position: fixed;
inset: 0;
z-index: 2000;
background-color: #000;
}
#reader-modal-container.visible {
display: block;
}
iframe {
width: 100%;
height: 100%;
border: none;
}
/* --- MOBILE RESPONSIVE --- */
@media (max-width: 768px) {
#hero-section {
min-height: auto;
display: block;
padding-bottom: 2rem;
background-position: center;
}
.hero-content {
flex-direction: column;
align-items: center;
text-align: center;
gap: 1.5rem;
padding-top: 4rem; /* Spacer for top nav usually */
}
.poster-container {
width: 180px;
margin-bottom: 0;
}
.info-container {
align-items: center;
}
.manga-title {
font-size: 2rem;
}
.meta-row {
justify-content: center;
}
.action-row {
flex-direction: column;
width: 100%;
}
.btn-primary, .btn-secondary {
width: 100%;
justify-content: center;
}
.synopsis-text {
-webkit-line-clamp: 3;
font-size: 0.9rem;
}
.chapter-item {
padding: 8px 10px;
}
.chapter-status-indicator {
height: 30px;
margin-right: 10px;
}
}
</style>
</head>
<body>
<!-- Hero Section -->
<header id="hero-section">
<div class="hero-overlay"></div>
<div class="hero-content">
<div class="poster-container">
<img
id="manga-poster"
class="poster-image"
src="https://placehold.co/400x600/202020/666?text=..."
alt="Manga Cover"
/>
</div>
<div class="info-container">
<div class="meta-row" id="meta-tags">
<!-- Populated by JS -->
</div>
<h1 id="manga-title" class="manga-title">Loading...</h1>
<p id="manga-synopsis" class="synopsis-text">
Loading synopsis...
</p>
<div class="action-row">
<button id="continue-reading-btn" class="btn-primary">
<i class="fa-solid fa-play"></i> Start Reading
</button>
<button id="add-list-btn" class="btn-secondary">
<i class="fa-solid fa-plus"></i> Add to List
</button>
</div>
</div>
</div>
</header>
<main id="main-content">
<div class="tabs-container">
<button class="tab-btn active" data-tab="chapters">Chapters</button>
<button class="tab-btn" data-tab="details" style="display:none">Details</button> <!-- Hidden for now -->
</div>
<div id="tab-chapters" class="tab-panel">
<div class="list-controls">
<span style="color:var(--text-secondary); font-size: 0.9rem;" id="chapter-count-label"></span>
<div id="pagination-top" class="pagination-controls"></div>
</div>
<ul id="chapter-list">
<!-- Chapters generated here -->
</ul>
<div class="list-controls" style="margin-top: 1rem; justify-content: center;">
<div id="pagination-bottom" class="pagination-controls"></div>
</div>
</div>
</main>
<div id="reader-modal-container"></div>
<script>
document.addEventListener("DOMContentLoaded", () => {
// --- DOM Elements ---
const els = {
hero: document.getElementById("hero-section"),
poster: document.getElementById("manga-poster"),
title: document.getElementById("manga-title"),
synopsis: document.getElementById("manga-synopsis"),
metaTags: document.getElementById("meta-tags"),
continueBtn: document.getElementById("continue-reading-btn"),
addListBtn: document.getElementById("add-list-btn"),
chapterList: document.getElementById("chapter-list"),
paginationTop: document.getElementById("pagination-top"),
paginationBottom: document.getElementById("pagination-bottom"),
countLabel: document.getElementById("chapter-count-label"),
modal: document.getElementById("reader-modal-container"),
};
// --- State ---
const getServerUrl = () => {
const extensionServerIp = localStorage.getItem("extension_server_ip") || "localhost";
const url = "http://" + extensionServerIp + ":7275";
console.log("[manga-info] serverUrl=", url);
return url;
};
const serverUrl = getServerUrl();
let state = {
mangaId: null,
source: "jikan", // default
title: "",
chapters: [], // Full list of chapters
currentPage: 1,
itemsPerPage: 30,
readingHistory: {},
mangaDetails: null
};
// --- Initialization ---
function getUrlParams() {
const params = new URLSearchParams(window.location.search);
return {
source: params.get("source") || "jikan",
id: params.get("id"),
};
}
async function init() {
const params = getUrlParams();
if (!params.id) {
els.title.textContent = "Error: No ID provided";
return;
}
state.mangaId = params.id;
state.source = params.source;
// Load History
loadReadingHistory();
// Fetch Data
try {
if (state.source === "mangadex") {
await loadMangaDexData();
} else {
await loadJikanData();
}
// Render UI
updateHeroSection();
updateContinueButton();
renderPagination();
renderChapterList();
} catch (e) {
console.error(e);
els.title.textContent = "Error loading manga";
els.synopsis.textContent = e.message;
}
}
// --- Data Fetching ---
async function loadJikanData() {
// 1. Details
const detRes = await fetch(`https://api.jikan.moe/v4/manga/${state.mangaId}/full`);
if(!detRes.ok) throw new Error("Jikan API Error");
const data = (await detRes.json()).data;
state.mangaDetails = {
title: data.title,
image: data.images?.jpg?.large_image_url,
banner: data.images?.jpg?.large_image_url, // Jikan doesn't provide banners usually, fallback
synopsis: data.synopsis,
status: data.status,
year: data.published?.from ? new Date(data.published.from).getFullYear() : "N/A",
authors: data.authors?.map(a => a.name).join(", "),
genres: data.genres?.map(g => g.name).slice(0,3)
};
state.title = data.title;
// 2. Chapters (From local proxy/server)
const chRes = await fetch(`${serverUrl}/chapters/${state.mangaId}`);
const chData = await chRes.json();
// Normalize Jikan Chapters
state.chapters = chData.chapters.map(ch => ({
id: String(ch.chapter_number), // ID is the number for Jikan
number: ch.chapter_number,
title: ch.title || `Chapter ${ch.chapter_number}`,
date: null
})).reverse(); // Newest first
}
async function loadMangaDexData() {
// 1. Details
const detRes = await fetch(`${serverUrl}/mangadex/manga/${state.mangaId}`);
if(!detRes.ok) throw new Error("MangaDex API Error");
const data = await detRes.json();
const attrs = data.attributes;
const title = attrs.title.en || Object.values(attrs.title)[0];
const banner = `url('${serverUrl}/manga/${state.mangaId}/banner')`; // Use backend proxy for banner
state.mangaDetails = {
title: title,
image: data.image_url || `https://placehold.co/400x600/141414/333?text=${title}`,
banner: null, // Will be handled by CSS background logic
synopsis: attrs.description.en || "No description.",
status: attrs.status,
year: attrs.year || "N/A",
authors: "", // Simplified
genres: attrs.tags.filter(t => t.group === 'genre').map(t => t.attributes.name.en).slice(0,3)
};
state.title = title;
// 2. Chapters (Fetch all IDs then paginate logic is simplified for this demo,
// but in production for MD you usually fetch by page.
// To satisfy "Client side pagination" request for "long lists", we fetch a large chunk or all.)
// Note: For a robust implementation with thousands of chapters, we should use server pagination.
// For this UI demo, we will fetch the first 500 or so to populate the list.
const chRes = await fetch(`${serverUrl}/mangadex/manga/${state.mangaId}/chapters?limit=500`);
const chData = await chRes.json();
state.chapters = chData.chapters.map(ch => ({
id: ch.id, // UUID
number: ch.attributes.chapter,
title: ch.attributes.title,
date: ch.attributes.publishAt,
group: ch.relationships.find(r => r.type === 'scanlation_group')?.attributes?.name
}));
}
// --- History Logic ---
function loadReadingHistory() {
const historyRaw = localStorage.getItem("reading_history");
if (!historyRaw) return;
try {
const historyArr = JSON.parse(historyRaw);
// Find entry for this manga and source
const entry = historyArr.find(item =>
String(item.mangaId) === String(state.mangaId) &&
item.source === state.source
);
if (entry && entry.chapters) {
state.readingHistory = entry.chapters;
}
} catch (e) {
console.error("Failed to parse reading history", e);
}
}
function getChapterStatus(chapterId) {
const chHistory = state.readingHistory[chapterId];
if (!chHistory) return "unread";
if (chHistory.state === "finished") return "read";
if (chHistory.state === "ongoing") return "ongoing";
return "unread";
}
function getLastReadChapter() {
// Find chapter with highest timestamp
let lastChId = null;
let maxTime = 0;
for (const [id, data] of Object.entries(state.readingHistory)) {
if (data.timestamp > maxTime) {
maxTime = data.timestamp;
lastChId = id;
}
}
if (!lastChId) return null;
// Find the object in state.chapters
return state.chapters.find(c => String(c.id) === String(lastChId));
}
// --- Render Functions ---
function updateHeroSection() {
const d = state.mangaDetails;
document.title = `${d.title} - Manga`;
// Background
if (state.source === "jikan") {
// Jikan fallback background
try {
els.hero.style.backgroundImage = `url('${serverUrl}/manga/${state.mangaId}/banner')`;
} catch (e) {
els.hero.style.backgroundImage = `url('${d.image}')`;
}
} else {
els.hero.style.backgroundImage = `url('${serverUrl}/manga/${state.mangaId}/banner')`;
}
els.poster.src = d.image;
els.title.textContent = d.title;
els.synopsis.textContent = d.synopsis;
// Meta Tags
els.metaTags.innerHTML = `
<span class="meta-tag accent">${state.source.toUpperCase()}</span>
<span class="meta-tag">${d.year}</span>
<span class="meta-tag" style="text-transform:capitalize">${d.status}</span>
${d.genres.map(g => `<span class="meta-tag">${g}</span>`).join('')}
`;
}
function updateContinueButton() {
const lastRead = getLastReadChapter();
if (lastRead) {
els.continueBtn.innerHTML = `<i class="fa-solid fa-play"></i> Continue Ch ${lastRead.number}`;
els.continueBtn.onclick = () => openReader(lastRead.id);
} else {
// Find first chapter (last in array usually if sorted desc, but let's be safe)
const firstCh = state.chapters[state.chapters.length - 1]; // Assuming desc order
if (firstCh) {
els.continueBtn.innerHTML = `<i class="fa-solid fa-book-open"></i> Start Reading`;
els.continueBtn.onclick = () => openReader(firstCh.id);
} else {
els.continueBtn.innerHTML = "No Chapters";
els.continueBtn.disabled = true;
}
}
}
function renderPagination() {
const totalPages = Math.ceil(state.chapters.length / state.itemsPerPage);
if (totalPages <= 1) {
els.paginationTop.style.display = 'none';
els.paginationBottom.style.display = 'none';
return;
}
const createButtons = (container) => {
container.innerHTML = '';
// Prev
const prev = document.createElement('button');
prev.className = 'page-btn';
prev.innerHTML = '<i class="fas fa-chevron-left"></i>';
prev.disabled = state.currentPage === 1;
prev.onclick = () => { state.currentPage--; renderChapterList(); renderPagination(); };
container.appendChild(prev);
// Simple page info (Desktop style often uses numbers, keeping it simple for logic)
const info = document.createElement('span');
info.textContent = `${state.currentPage} / ${totalPages}`;
info.style.color = 'var(--text-secondary)';
info.style.alignSelf = 'center';
info.style.margin = '0 10px';
container.appendChild(info);
// Next
const next = document.createElement('button');
next.className = 'page-btn';
next.innerHTML = '<i class="fas fa-chevron-right"></i>';
next.disabled = state.currentPage === totalPages;
next.onclick = () => { state.currentPage++; renderChapterList(); renderPagination(); };
container.appendChild(next);
};
createButtons(els.paginationTop);
createButtons(els.paginationBottom);
els.paginationTop.style.display = 'flex';
els.paginationBottom.style.display = 'flex';
}
function renderChapterList() {
els.chapterList.innerHTML = '';
const start = (state.currentPage - 1) * state.itemsPerPage;
const end = start + state.itemsPerPage;
const currentSlice = state.chapters.slice(start, end);
els.countLabel.textContent = `${state.chapters.length} Chapters`;
if (currentSlice.length === 0) {
els.chapterList.innerHTML = '<li style="padding:20px; text-align:center; color:gray">No chapters found.</li>';
return;
}
currentSlice.forEach(ch => {
const status = getChapterStatus(ch.id);
const li = document.createElement('li');
li.className = `chapter-item status-${status}`;
let metaText = `Ch. ${ch.number}`;
if (ch.date) {
const d = new Date(ch.date).toLocaleDateString();
metaText += `${d}`;
}
if (ch.group) {
metaText += `${ch.group}`;
}
li.innerHTML = `
<div class="chapter-status-indicator"></div>
<div class="chapter-info">
<span class="chapter-title">${ch.title || 'Chapter ' + ch.number}</span>
<div class="chapter-meta">${metaText}</div>
</div>
<div class="chapter-actions">
<button class="icon-btn add-btn" title="Add to Queue"><i class="fas fa-list-ul"></i></button>
<button class="icon-btn download-btn" title="Download"><i class="fas fa-download"></i></button>
</div>
`;
li.onclick = (e) => {
// Prevent clicking buttons from triggering read
if (e.target.closest('.chapter-actions')) return;
openReader(ch.id);
};
// Add to List Action (using parent method from prompt context)
li.querySelector('.add-btn').onclick = () => {
if (window.parent && typeof window.parent.openListManager === "function") {
window.parent.openListManager({
type: "manga",
id: state.mangaId,
title: state.title,
items: [String(ch.id)],
source: state.source,
});
} else {
alert("List Manager not found in parent window.");
}
};
// Download Action
li.querySelector('.download-btn').onclick = () => {
window.open(`/download-manga/site/${state.source}/${state.mangaId}/${ch.id}`, "_blank");
};
els.chapterList.appendChild(li);
});
}
// --- Interaction ---
function openReader(chapterId) {
// Update local storage history immediately for UI responsiveness (mocking)
// In a real app, the reader usually updates the history.
// Here we just open the iframe.
const url = `/read/${state.source}/${state.mangaId}/${chapterId}`;
els.modal.innerHTML = `<iframe src="${url}" allowfullscreen></iframe>`;
els.modal.classList.add("visible");
document.body.style.overflow = "hidden";
}
window.addEventListener("message", (event) => {
if (event.data === "close-reader-modal") {
els.modal.classList.remove("visible");
els.modal.innerHTML = "";
document.body.style.overflow = "auto";
// Refresh history logic on close
loadReadingHistory();
updateContinueButton();
renderChapterList(); // Re-render to update status indicators
}
});
// Add List Button (Series level)
els.addListBtn.addEventListener("click", () => {
if (window.parent && typeof window.parent.openListManager === "function") {
// Add all loaded chapters
const allIds = state.chapters.map(c => String(c.id));
window.parent.openListManager({
type: "manga",
id: state.mangaId,
title: state.title,
items: allIds,
source: state.source,
});
}
});
// Run
init();
});
</script>
</body>
</html>

1238
animex/manga.html Normal file

File diff suppressed because it is too large Load Diff

585
animex/manga.py Normal file
View File

@@ -0,0 +1,585 @@
import requests
import json
import time
import os
import collections
import getpass
import re
# --- Dependency Check and Setup ---
try:
import google.generativeai as genai
except ImportError:
print("Error: The 'google-generativeai' library is not installed.")
print("Please install it by running: pip install google-generativeai")
exit()
# --- Configuration ---
JIKAN_API_BASE_URL = "https://api.jikan.moe/v4"
CONTENT_FILE = "manga_content.json"
CONFIG_FILE = "config.ini"
# Jikan rate limiting
REQUEST_TIMESTAMPS = collections.deque()
REQUEST_LIMIT = 3 # 3 requests per second
TIME_WINDOW = 1 # 1 second
# --- API Key and Configuration Management ---
def get_api_key():
"""Gets the Google AI API key, prompting the user if not found."""
# Check environment variable first
api_key = os.getenv("GOOGLE_API_KEY")
if api_key:
print("Loaded Google API Key from environment variable.")
input("Press Enter to continue...")
return api_key
# Check config file next
if os.path.exists(CONFIG_FILE):
with open(CONFIG_FILE, 'r') as f:
config = json.load(f)
api_key = config.get("GOOGLE_API_KEY")
if api_key:
print("Loaded Google API Key from config.ini.")
input("Press Enter to continue...")
return api_key
# If not found, prompt the user
print("\n--- Google AI API Key Required ---")
print("To use the AI generation feature, you need a Google AI API Key.")
print("You can get a free key from Google AI Studio.")
print("The key will be stored locally in 'config.ini' so you don't have to enter it again.")
api_key = getpass.getpass("Please enter your Google AI API Key: ")
# Save the key to config.ini for future use
with open(CONFIG_FILE, 'w') as f:
json.dump({"GOOGLE_API_KEY": api_key}, f)
print("API Key saved to config.ini.")
return api_key
# --- Jikan API Interaction with Rate Limiting ---
def jikan_api_request(endpoint, params=None):
"""
Makes a rate-limited request to the Jikan API.
Waits if the request limit has been reached in the last second.
"""
global REQUEST_TIMESTAMPS
while True:
now = time.time()
# Remove timestamps older than the time window
while REQUEST_TIMESTAMPS and REQUEST_TIMESTAMPS[0] < now - TIME_WINDOW:
REQUEST_TIMESTAMPS.popleft()
if len(REQUEST_TIMESTAMPS) < REQUEST_LIMIT:
break
# Calculate sleep time to respect the rate limit
sleep_time = (REQUEST_TIMESTAMPS[0] + TIME_WINDOW) - now + 0.05 # small buffer
print(f"Jikan rate limit reached. Waiting for {sleep_time:.2f} seconds...")
time.sleep(sleep_time)
try:
REQUEST_TIMESTAMPS.append(time.time())
print(f"Making Jikan request to: {JIKAN_API_BASE_URL}{endpoint}")
response = requests.get(f"{JIKAN_API_BASE_URL}{endpoint}", params=params)
response.raise_for_status() # Raises an HTTPError for bad responses (4xx or 5xx)
return response.json()
except requests.exceptions.RequestException as e:
print(f"\n--- Jikan API Error --- \n{e}\n------------------")
return None
# --- Helper Functions ---
def clear_screen():
"""Clears the console screen."""
os.system('cls' if os.name == 'nt' else 'clear')
def get_choice(max_choice, allow_back=True):
"""Gets and validates a user's integer choice."""
while True:
try:
prompt = "> "
choice = input(prompt)
if allow_back and choice.lower() == 'b':
return 'b'
choice = int(choice)
if 1 <= choice <= max_choice:
return choice
else:
print(f"Invalid choice. Please enter a number between 1 and {max_choice}.")
except ValueError:
print("Invalid input. Please enter a number.")
def format_manga_data(manga_obj):
"""Formats Jikan manga data into the structure needed for manga_content.json."""
return {
"id": manga_obj['mal_id'],
"name": manga_obj.get('title_english') or manga_obj.get('title'),
"image": manga_obj['images']['jpg']['large_image_url'],
# Optional: Add description for the hero section if needed
"description": manga_obj.get('synopsis', ''),
"year": manga_obj.get('published', {}).get('prop', {}).get('from', {}).get('year')
}
def search_and_select_manga():
"""Prompts user to search for a manga, displays results, and returns the selected one."""
query = input("Enter search term (or 'b' to go back): ")
if query.lower() == 'b':
return None
results = jikan_api_request("/manga", params={"q": query, "limit": 10})
if not results or not results.get('data'):
print("No results found.")
input("Press Enter to continue...")
return None
clear_screen()
print(f"--- Search Results for '{query}' ---")
for i, manga in enumerate(results['data'], 1):
year = manga.get('published', {}).get('prop', {}).get('from', {}).get('year', 'N/A')
print(f"[{i}] {manga.get('title_english') or manga.get('title')} ({manga.get('type', 'N/A')}, {year})")
print("\n[b] Back to previous menu")
print("\nSelect a manga to add:")
choice = get_choice(len(results['data']))
if choice == 'b':
return None
return results['data'][choice - 1]
# --- Management Logic ---
def manage_spotlight(data):
"""Handles logic for managing the spotlight section."""
while True:
clear_screen()
print("--- Manage Spotlight Section ---")
if not data['spotlight']:
print("Spotlight is currently empty.")
else:
for i, item in enumerate(data['spotlight'], 1):
print(f"[{i}] {item['name']} (ID: {item['id']})")
print("\nOptions:")
print("[1] Add a manga to Spotlight")
print("[2] Remove a manga from Spotlight")
print("[b] Back to Main Menu")
choice = input("> ").lower()
if choice == '1':
manga_obj = search_and_select_manga()
if manga_obj:
if any(item['id'] == manga_obj['mal_id'] for item in data['spotlight']):
print(f"'{manga_obj['title']}' is already in the spotlight.")
else:
formatted = format_manga_data(manga_obj)
data['spotlight'].append(formatted)
print(f"Added '{formatted['name']}' to spotlight.")
input("Press Enter to continue...")
elif choice == '2':
if not data['spotlight']:
print("Nothing to remove.")
input("Press Enter to continue...")
continue
print("Enter the number of the manga to remove (or 'b' to cancel):")
remove_choice = get_choice(len(data['spotlight']))
if remove_choice != 'b':
removed = data['spotlight'].pop(remove_choice - 1)
print(f"Removed '{removed['name']}' from spotlight.")
input("Press Enter to continue...")
elif choice == 'b':
return
def manage_sections(data):
"""Handles logic for managing horizontal sections."""
while True:
clear_screen()
print("--- Manage Horizontal Sections ---")
if not data['sections']:
print("No sections created yet.")
else:
for i, section in enumerate(data['sections'], 1):
print(f"[{i}] {section['title']} ({len(section['items'])} items)")
print("\nOptions:")
print("[1] Create a new section")
print("[2] Edit an existing section")
print("[3] Delete a section")
print("[b] Back to Main Menu")
choice = input("> ").lower()
if choice == '1':
title = input("Enter title for new section: ")
data['sections'].append({"title": title, "items": []})
print(f"Section '{title}' created.")
input("Press Enter...")
elif choice == '2':
if not data['sections']:
print("No sections to edit.")
input("Press Enter...")
continue
for i, section in enumerate(data['sections'], 1):
print(f"[{i}] {section['title']} ({len(section['items'])} items)")
print("Select a section to edit:")
edit_choice = get_choice(len(data['sections']))
if edit_choice != 'b':
edit_section_menu(data['sections'][edit_choice - 1])
elif choice == '3':
if not data['sections']:
print("No sections to delete.")
input("Press Enter...")
continue
print("Select a section to delete:")
delete_choice = get_choice(len(data['sections']))
if delete_choice != 'b':
removed = data['sections'].pop(delete_choice - 1)
print(f"Deleted section '{removed['title']}'.")
input("Press Enter...")
elif choice == 'b':
return
def edit_section_menu(section):
"""Menu for editing a specific section."""
while True:
clear_screen()
print(f"--- Editing Section: {section['title']} ---")
if not section['items']:
print("This section is empty.")
else:
for i, item in enumerate(section['items'], 1):
print(f" [{i}] {item['name']} (ID: {item['id']})")
print("\nOptions:")
print("[1] Add a manga to this section (Manual Search)")
print("[2] Remove a manga from this section")
print("[3] Auto-populate this section (from Jikan)")
print("[4] Generate content with AI")
print("[5] Rename this section")
print("[b] Back to Sections Menu")
choice = input("> ").lower()
if choice == '1':
manga_obj = search_and_select_manga()
if manga_obj:
if any(item['id'] == manga_obj['mal_id'] for item in section['items']):
print(f"'{manga_obj['title']}' is already in this section.")
else:
formatted = format_manga_data(manga_obj)
section['items'].append(formatted)
print(f"Added '{formatted['name']}' to '{section['title']}'.")
input("Press Enter...")
elif choice == '2':
if not section['items']:
print("Nothing to remove.")
input("Press Enter...")
continue
print("Enter the number of the manga to remove:")
remove_choice = get_choice(len(section['items']))
if remove_choice != 'b':
removed = section['items'].pop(remove_choice - 1)
print(f"Removed '{removed['name']}' from '{section['title']}'.")
input("Press Enter...")
elif choice == '3':
auto_populate_section(section)
elif choice == '4':
generate_with_ai(section)
elif choice == '5':
new_title = input(f"Enter new title for '{section['title']}': ")
section['title'] = new_title
print("Section renamed.")
input("Press Enter...")
elif choice == 'b':
return
def auto_populate_section(section):
"""Automatically populates a section from a Jikan endpoint."""
clear_screen()
print(f"--- Auto-Populate Section: {section['title']} ---")
print("Select a category to populate from:")
print("[1] Top Manga by Popularity")
print("[2] Top Manhwa")
print("[3] Top Publishing Manga")
print("[b] Cancel")
choice = get_choice(3)
if choice == 'b':
return
endpoint_map = {
1: ("/top/manga", {"filter": "bypopularity", "limit": 15}),
2: ("/manga", {"type": "manhwa", "order_by": "popularity", "limit": 15}),
3: ("/top/manga", {"filter": "publishing", "limit": 15})
}
endpoint, params = endpoint_map[choice]
results = jikan_api_request(endpoint, params=params)
if not results or not results.get('data'):
print("Could not fetch data for this category.")
input("Press Enter...")
return
added_count = 0
skipped_count = 0
for manga_obj in results['data']:
if not any(item['id'] == manga_obj['mal_id'] for item in section['items']):
section['items'].append(format_manga_data(manga_obj))
added_count += 1
else:
skipped_count += 1
print(f"Added {added_count} new items and skipped {skipped_count} duplicates in '{section['title']}'.")
input("Press Enter...")
def generate_with_ai(section):
"""Generates content for a section using Google's Generative AI."""
clear_screen()
print(f"--- AI Content Generation for: {section['title']} ---")
# 1. Get and configure API Key
try:
api_key = get_api_key()
genai.configure(api_key=api_key)
model = genai.GenerativeModel('gemini-1.5-flash')
except Exception as e:
print(f"An error occurred while configuring the AI model: {e}")
input("Press Enter to return.")
return
# 2. Get user prompt
print("Describe the kind of manga/manhwa you want to find.")
print("Examples: 'top 10 isekai manga', 'psychological thrillers similar to Monster', 'wholesome slice of life'")
user_prompt = input("\nEnter your prompt: ")
if not user_prompt:
return
# 3. Query the AI model with improved prompt
print("\nAsking the AI for suggestions... this may take a moment.")
full_prompt = f"""List exactly 10 manga that fit this description: '{user_prompt}'.
IMPORTANT: Follow this exact format for your response:
- Return ONLY the manga titles
- One title per line
- No numbers, bullets, dashes, or prefixes
- No descriptions or explanations
- No extra text before or after the list
- Use the most commonly known English or romanized title
Example format:
Berserk
Vagabond
One Piece
Your response for '{user_prompt}':"""
try:
response = model.generate_content(full_prompt)
if not response.text:
print("The AI returned an empty response. Please try again.")
input("Press Enter to return.")
return
# Clean and parse the response more robustly
ai_suggestions = []
lines = response.text.strip().split('\n')
for line in lines:
# Clean each line of common prefixes and formatting
cleaned = line.strip()
# Remove common prefixes like "1.", "- ", "• ", etc.
cleaned = re.sub(r'^[\d\.\-\\*\s]+', '', cleaned)
cleaned = cleaned.strip()
if cleaned and len(cleaned) > 1: # Ensure it's not just whitespace or single character
ai_suggestions.append(cleaned)
# Limit to 10 suggestions max
ai_suggestions = ai_suggestions[:10]
except Exception as e:
print(f"An error occurred while communicating with the AI: {e}")
input("Press Enter to return.")
return
if not ai_suggestions:
print("The AI didn't return any valid suggestions. Try a different prompt.")
input("Press Enter to return.")
return
print(f"\nAI suggested {len(ai_suggestions)} manga titles.")
# 4. Process suggestions: Search Jikan and get user confirmation
print("\n--- Confirm AI Suggestions ---")
print("For each suggestion, I will find the closest match on MyAnimeList.")
print("Please confirm if the match is correct.")
confirmed_manga = []
for i, suggestion in enumerate(ai_suggestions, 1):
print(f"\n[{i}/{len(ai_suggestions)}] Searching for: '{suggestion}'...")
results = jikan_api_request("/manga", params={"q": suggestion, "limit": 3})
if not results or not results.get('data'):
print(f"--> Could not find any match for '{suggestion}'.")
continue
# Show top match but also alternatives if the first doesn't seem right
match = results['data'][0]
title = match.get('title_english') or match.get('title')
year = match.get('published', {}).get('prop', {}).get('from', {}).get('year', 'N/A')
print(f"--> Best match: '{title}' ({match.get('type', 'N/A')}, {year})")
# Show alternatives if available
if len(results['data']) > 1:
print(" Alternatives:")
for j, alt in enumerate(results['data'][1:3], 2):
alt_title = alt.get('title_english') or alt.get('title')
alt_year = alt.get('published', {}).get('prop', {}).get('from', {}).get('year', 'N/A')
print(f" [{j}] {alt_title} ({alt.get('type', 'N/A')}, {alt_year})")
while True:
if len(results['data']) > 1:
choice = input(" Choose: [1] Use best match, [2-3] Use alternative, [s] Skip, [Enter] Use best match: ").strip().lower()
else:
choice = input(" [Enter] Add this manga, [s] Skip: ").strip().lower()
if choice == '' or choice == '1':
selected_match = results['data'][0]
break
elif choice == 's':
selected_match = None
break
elif choice in ['2', '3'] and len(results['data']) > int(choice) - 1:
selected_match = results['data'][int(choice) - 1]
break
else:
print(" Invalid choice. Please try again.")
if selected_match:
formatted = format_manga_data(selected_match)
# Avoid adding duplicates
if any(a['id'] == formatted['id'] for a in confirmed_manga):
print(f"--> Already added '{formatted['name']}'. Skipping.")
else:
confirmed_manga.append(formatted)
print(f"--> Added '{formatted['name']}' to the list.")
else:
print(f"--> Skipped '{suggestion}'.")
# 5. Final review and add to section
if not confirmed_manga:
print("\nNo new manga were confirmed. Returning to menu.")
input("Press Enter...")
return
clear_screen()
print("--- Final Review ---")
print("The following new manga will be added to the section:")
for item in confirmed_manga:
print(f"- {item['name']}")
final_confirm = input("\nAdd these items to the section? [Y/n]: ").lower()
if final_confirm == '' or final_confirm == 'y':
added_count = 0
skipped_count = 0
for manga in confirmed_manga:
if not any(item['id'] == manga['id'] for item in section['items']):
section['items'].append(manga)
added_count += 1
else:
skipped_count += 1
print(f"\nSuccessfully added {added_count} new manga.")
if skipped_count > 0:
print(f"Skipped {skipped_count} manga that were already in the section.")
else:
print("Operation cancelled. No changes were made.")
input("Press Enter to continue...")
# --- Main Application ---
def main():
"""Main function to run the content manager."""
data = None
# Check if a content file exists and prompt the user.
if os.path.exists(CONTENT_FILE):
clear_screen()
print("--- Welcome Back ---")
print(f"Found existing content file: '{CONTENT_FILE}'")
print("\nWhat would you like to do?")
print("[1] Load the existing content")
print("[2] Start from scratch (Warning: saving will overwrite the old file)")
while data is None:
choice = input("> ")
if choice == '1':
try:
with open(CONTENT_FILE, 'r') as f:
data = json.load(f)
# Ensure the basic structure exists, in case the file is malformed
if 'spotlight' not in data: data['spotlight'] = []
if 'sections' not in data: data['sections'] = []
print("Content loaded successfully.")
except (json.JSONDecodeError, FileNotFoundError):
print(f"Error: Could not read or parse '{CONTENT_FILE}'. Starting from scratch.")
data = {"spotlight": [], "sections": []}
elif choice == '2':
print("Starting with a blank slate.")
data = {"spotlight": [], "sections": []}
else:
print("Invalid choice. Please enter 1 or 2.")
input("Press Enter to continue...")
else:
# If no content file exists, start from scratch automatically.
print(f"No '{CONTENT_FILE}' found. Starting with a blank slate.")
data = {"spotlight": [], "sections": []}
input("Press Enter to continue...")
while True:
clear_screen()
print("--- Manga Content Manager ---")
print(" (with AI-Powered Suggestions)")
print("\nSelect an option:")
print("[1] Manage Spotlight Section")
print("[2] Manage Horizontal Sections")
print("[3] Save and Exit")
print("[4] Exit Without Saving")
choice = input("> ")
if choice == '1':
manage_spotlight(data)
elif choice == '2':
manage_sections(data)
elif choice == '3':
with open(CONTENT_FILE, 'w') as f:
json.dump(data, f, indent=4)
print(f"Content saved to {CONTENT_FILE}.")
break
elif choice == '4':
print("Exiting without saving changes.")
break
else:
print("Invalid option. Please try again.")
input("Press Enter to continue...")
if __name__ == "__main__":
main()

520
animex/manga_content.json Normal file
View File

@@ -0,0 +1,520 @@
{
"spotlight": [
{
"id": 113138,
"name": "Jujutsu Kaisen",
"image": "https://cdn.myanimelist.net/images/manga/3/210341l.jpg",
"description": "Hidden in plain sight, an age-old conflict rages on. Supernatural monsters known as Curses terrorize humanity from the shadows, and powerful humans known as Jujutsu sorcerers use mystical arts to exterminate them. When high school student Yuuji Itadori finds a dried-up finger of the legendary Curse Sukuna Ryoumen, he suddenly finds himself joining this bloody conflict.\n\nAttacked by a Curse attracted to the finger's power, Yuuji makes a reckless decision to protect himself, gaining the power to combat Curses in the process but also unwittingly unleashing the malicious Sukuna into the world once more. Though Yuuji can control and confine Sukuna to his own body, the Jujutsu world classifies Yuuji as a dangerous, high-level Curse who must be exterminated.\n\nDetained and sentenced to death, Yuuji meets Satoru Gojou\u2014a teacher at Jujutsu High School\u2014who explains that despite his imminent execution, there is an alternative for him. Being a rare vessel to Sukuna, if Yuuji were to die, then Sukuna would perish too. Therefore, if Yuuji were to consume the many other remnants of Sukuna, then Yuuji's subsequent execution would truly eradicate the malicious demon. Taking up this chance to make the world safer and live his life for a little longer, Yuuji enrolls in Tokyo Prefectural Jujutsu High School, jumping headfirst into a harsh and unforgiving battlefield.\n\n[Written by MAL Rewrite]",
"year": 2018,
"jp_name": "呪術廻戦"
},
{
"id": 2,
"name": "Berserk",
"image": "https://cdn.myanimelist.net/images/manga/1/157897l.jpg",
"description": "Guts, a former mercenary now known as the Black Swordsman, is out for revenge. After a tumultuous childhood, he finally finds someone he respects and believes he can trust, only to have everything fall apart when this person takes away everything important to Guts for the purpose of fulfilling his own desires. Now marked for death, Guts becomes condemned to a fate in which he is relentlessly pursued by demonic beings.\n\nSetting out on a dreadful quest riddled with misfortune, Guts, armed with a massive sword and monstrous strength, will let nothing stop him, not even death itself, until he is finally able to take the head of the one who stripped him\u2014and his loved one\u2014of their humanity.\n\n[Written by MAL Rewrite]\n\nIncluded one-shot:\nVolume 14: Berserk: The Prototype",
"year": 1989,
"jp_name": "ベルセルク"
},
{
"id": 13,
"name": "One Piece",
"image": "https://cdn.myanimelist.net/images/manga/2/253146l.jpg",
"description": "Gol D. Roger, a man referred to as the King of the Pirates, is set to be executed by the World Government. But just before his demise, he confirms the existence of a great treasure, One Piece, located somewhere within the vast ocean known as the Grand Line. Announcing that One Piece can be claimed by anyone worthy enough to reach it, the King of the Pirates is executed and the Great Age of Pirates begins.\n\nTwenty-two years later, a young man by the name of Monkey D. Luffy is ready to embark on his own adventure, searching for One Piece and striving to become the new King of the Pirates. Armed with just a straw hat, a small boat, and an elastic body, he sets out on a fantastic journey to gather his own crew and a worthy ship that will take them across the Grand Line to claim the greatest status on the high seas.\n\n[Written by MAL Rewrite]",
"year": 1997,
"jp_name": "ワンピース"
},
{
"id": 96792,
"name": "Demon Slayer: Kimetsu no Yaiba",
"image": "https://cdn.myanimelist.net/images/manga/3/179023l.jpg",
"description": "Tanjirou Kamado lives with his impoverished family on a remote mountain. As the oldest sibling, he took upon the responsibility of ensuring his family's livelihood after the death of his father. On a cold winter day, he goes down to the local village in order to sell some charcoal. As dusk falls, he is forced to spend the night in the house of a curious man who cautions him of strange creatures that roam the night: malevolent demons who crave human flesh.\n\nWhen he finally makes his way home, Tanjirou's worst nightmare comes true. His entire family has been brutally slaughtered with the sole exception of his sister Nezuko, who has turned into a flesh-eating demon. Engulfed in hatred and despair, Tanjirou desperately tries to stop Nezuko from attacking other people, setting out on a journey to avenge his family and find a way to turn his beloved sister back into a human.\n\n[Written by MAL Rewrite]",
"year": 2016,
"jp_name": "鬼滅の刃"
},
{
"id": 23390,
"name": "Attack on Titan",
"image": "https://cdn.myanimelist.net/images/manga/2/37846l.jpg",
"description": "Hundreds of years ago, horrifying creatures which resembled humans appeared. These mindless, towering giants, called Titans, proved to be an existential threat, as they preyed on whatever humans they could find in order to satisfy a seemingly unending appetite. Unable to effectively combat the Titans, mankind was forced to barricade themselves within large walls surrounding what may very well be humanity's last safe haven in the world.\n\nIn the present day, life within the walls has finally found peace, since the residents have not dealt with Titans for many years. Eren Yeager, Mikasa Ackerman, and Armin Arlert are three young children who dream of experiencing all that the world has to offer, having grown up hearing stories of the wonders beyond the walls. But when the state of tranquility is suddenly shattered by the attack of a massive 60-meter Titan, they quickly learn just how cruel the world can be. On that day, Eren makes a promise to himself that he will do whatever it takes to eradicate every single Titan off the face of the Earth, with the hope that one day, humanity will once again be able to live outside the walls without fear.\n\n[Written by MAL Rewrite]",
"year": 2009,
"jp_name": "進撃の巨人"
},
{
"id": 116778,
"name": "Chainsaw Man",
"image": "https://cdn.myanimelist.net/images/manga/3/216464l.jpg",
"description": "Denji has a simple dream\u2014to live a happy and peaceful life, spending time with a girl he likes. This is a far cry from reality, however, as Denji is forced by the yakuza into killing devils in order to pay off his crushing debts. Using his pet devil Pochita as a weapon, he is ready to do anything for a bit of cash.\n\nUnfortunately, he has outlived his usefulness and is murdered by a devil in contract with the yakuza. However, in an unexpected turn of events, Pochita merges with Denji's dead body and grants him the powers of a chainsaw devil. Now able to transform parts of his body into chainsaws, a revived Denji uses his new abilities to quickly and brutally dispatch his enemies. Catching the eye of the official devil hunters who arrive at the scene, he is offered work at the Public Safety Bureau as one of them. Now with the means to face even the toughest of enemies, Denji will stop at nothing to achieve his simple teenage dreams.\n\n[Written by MAL Rewrite]",
"year": 2018,
"jp_name": "チェンソーマン"
},
{
"id": 121496,
"name": "Solo Leveling",
"image": "https://cdn.myanimelist.net/images/manga/3/222295l.jpg",
"description": "Ten years ago, \"the Gate\" appeared and connected the real world with the realm of magic and monsters. To combat these vile beasts, ordinary people received superhuman powers and became known as \"Hunters.\" Twenty-year-old Sung Jin-Woo is one such Hunter, but he is known as the \"World's Weakest,\" owing to his pathetic power compared to even a measly E-Rank. Still, he hunts monsters tirelessly in low-rank Gates to pay for his mother's medical bills. \n\nHowever, this miserable lifestyle changes when Jin-Woo\u2014believing himself to be the only one left to die in a mission gone terribly wrong\u2014awakens in a hospital three days later to find a mysterious screen floating in front of him. This \"Quest Log\" demands that Jin-Woo completes an unrealistic and intense training program, or face an appropriate penalty. Initially reluctant to comply because of the quest's rigor, Jin-Woo soon finds that it may just transform him into one of the world's most fearsome Hunters. \n\n[Written by MAL Rewrite]",
"year": 2018,
"jp_name": "ソロレベル"
},
{
"id": 642,
"name": "Vinland Saga",
"image": "https://cdn.myanimelist.net/images/manga/2/188925l.jpg",
"description": "Thorfinn, son of one of the Vikings' greatest warriors, is among the finest fighters in the merry band of mercenaries run by the cunning Askeladd, an impressive feat for a person his age. However, Thorfinn is not part of the group for the plunder it entails\u2014instead, for having caused his family great tragedy, the boy has vowed to kill Askeladd in a fair duel. Not yet skilled enough to defeat him, but unable to abandon his vengeance, Thorfinn spends his boyhood with the mercenary crew, honing his skills on the battlefield among the war-loving Danes, where killing is just another pleasure of life.\n\nOne day, when Askeladd receives word that Danish prince Canute has been taken hostage, he hatches an ambitious plot\u2014one that will decide the next King of England and drastically alter the lives of Thorfinn, Canute, and himself. Set in 11th-century Europe, Vinland Saga tells a bloody epic in an era where violence, madness, and injustice are inescapable, providing a paradise for the battle-crazed and utter hell for the rest who live in it.\n\n[Written by MAL Rewrite]\n\nIncluded one-shots:\nVolume 23: Sayounara ga Chikai node (For Our Farewell Is Near)\nVolume 25: \"Assassin's Creed Valhalla\" Collab Bangai-hen (Assassin's Creed x Vinland Saga)",
"year": 2005,
"jp_name": "ヴィンランド・サガ"
}
],
"sections": [
{
"title": "Fan Favorites",
"items": [
{
"id": 25,
"name": "Fullmetal Alchemist",
"image": "https://cdn.myanimelist.net/images/manga/3/243675l.jpg"
},
{
"id": 1,
"name": "Monster",
"image": "https://cdn.myanimelist.net/images/manga/3/258224l.jpg"
},
{
"id": 656,
"name": "Vagabond",
"image": "https://cdn.myanimelist.net/images/manga/1/259070l.jpg"
},
{
"id": 90125,
"name": "Kaguya-sama: Love Is War",
"image": "https://cdn.myanimelist.net/images/manga/3/188896l.jpg"
},
{
"id": 21,
"name": "Death Note",
"image": "https://cdn.myanimelist.net/images/manga/1/258245l.jpg"
},
{
"id": 44489,
"name": "Land of the Lustrous",
"image": "https://cdn.myanimelist.net/images/manga/1/115443l.jpg"
},
{
"id": 26,
"name": "Hunter x Hunter",
"image": "https://cdn.myanimelist.net/images/manga/2/253119l.jpg"
},
{
"id": 657,
"name": "Real",
"image": "https://cdn.myanimelist.net/images/manga/2/115969l.jpg"
}
]
},
{
"title": "Trending Now",
"items": [
{
"id": 126146,
"name": "[Oshi no Ko]",
"image": "https://cdn.myanimelist.net/images/manga/3/233991l.jpg"
},
{
"id": 119161,
"name": "Spy x Family",
"image": "https://cdn.myanimelist.net/images/manga/3/219741l.jpg"
},
{
"id": 113138,
"name": "Jujutsu Kaisen",
"image": "https://cdn.myanimelist.net/images/manga/3/210341l.jpg"
},
{
"id": 118855,
"name": "That Summer",
"image": "https://cdn.myanimelist.net/images/manga/3/228279l.jpg"
},
{
"id": 133800,
"name": "Kedamono-tachi no Jikan: Kyouizon Shoukougun",
"image": "https://cdn.myanimelist.net/images/manga/2/249520l.jpg"
},
{
"id": 114755,
"name": "Change the World: Bloodthirsty Killer from Today",
"image": "https://cdn.myanimelist.net/images/manga/2/219770l.jpg"
},
{
"id": 127907,
"name": "Kaiju No. 8",
"image": "https://cdn.myanimelist.net/images/manga/3/252929l.jpg"
},
{
"id": 129868,
"name": "Tsuyuno-chan Likes to Show Off",
"image": "https://cdn.myanimelist.net/images/manga/3/234412l.jpg"
}
]
},
{
"title": "Action-Packed Adventures",
"items": [
{
"id": 11,
"name": "Naruto",
"image": "https://cdn.myanimelist.net/images/manga/3/249658l.jpg"
},
{
"id": 12,
"name": "Bleach",
"image": "https://cdn.myanimelist.net/images/manga/3/180031l.jpg"
},
{
"id": 42,
"name": "Dragon Ball",
"image": "https://cdn.myanimelist.net/images/manga/1/267793l.jpg"
},
{
"id": 86337,
"name": "Black Clover",
"image": "https://cdn.myanimelist.net/images/manga/2/166254l.jpg"
},
{
"id": 75989,
"name": "My Hero Academia",
"image": "https://cdn.myanimelist.net/images/manga/1/209370l.jpg"
},
{
"id": 31499,
"name": "Nisekoi: False Love",
"image": "https://cdn.myanimelist.net/images/manga/1/181212l.jpg"
},
{
"id": 100128,
"name": "The Promised Neverland",
"image": "https://cdn.myanimelist.net/images/manga/3/186922l.jpg"
},
{
"id": 4,
"name": "Yokohama Kaidashi Kikou",
"image": "https://cdn.myanimelist.net/images/manga/1/171813l.jpg"
}
]
},
{
"title": "Dark & Gritty",
"items": [
{
"id": 642,
"name": "Vinland Saga",
"image": "https://cdn.myanimelist.net/images/manga/2/188925l.jpg"
},
{
"id": 4632,
"name": "Goodnight Punpun",
"image": "https://cdn.myanimelist.net/images/manga/3/266834l.jpg"
},
{
"id": 33327,
"name": "Tokyo Ghoul",
"image": "https://cdn.myanimelist.net/images/manga/3/114037l.jpg"
},
{
"id": 100448,
"name": "I sold my life for ten thousand yen per year.",
"image": "https://cdn.myanimelist.net/images/manga/5/260043l.jpg"
},
{
"id": 3006,
"name": "JoJo's Bizarre Adventure Part 4: Diamond Is Unbreakable",
"image": "https://cdn.myanimelist.net/images/manga/3/269910l.jpg"
},
{
"id": 651,
"name": "Blade of the Immortal",
"image": "https://media.kitsu.app/manga/poster_images/1487/large.jpg"
},
{
"id": 104314,
"name": "Jagaaaaaan",
"image": "https://cdn.myanimelist.net/images/manga/2/273180l.jpg"
},
{
"id": 1706,
"name": "JoJo's Bizarre Adventure Part 7: Steel Ball Run",
"image": "https://cdn.myanimelist.net/images/manga/3/179882l.jpg"
}
]
},
{
"title": "Otherworldly Fantasies",
"items": [
{
"id": 70259,
"name": "Mushoku Tensei: Jobless Reincarnation",
"image": "https://cdn.myanimelist.net/images/manga/2/181049l.jpg"
},
{
"id": 126287,
"name": "Frieren: Beyond Journey's End",
"image": "https://cdn.myanimelist.net/images/manga/3/232121l.jpg"
},
{
"id": 89554,
"name": "That Time I Got Reincarnated as a Slime",
"image": "https://cdn.myanimelist.net/images/manga/1/157796l.jpg"
},
{
"id": 110485,
"name": "Record of Ragnarok",
"image": "https://cdn.myanimelist.net/images/manga/3/209957l.jpg"
},
{
"id": 85737,
"name": "Sensei no Sumika",
"image": "https://cdn.myanimelist.net/images/manga/2/148845l.jpg"
},
{
"id": 118586,
"name": "Sousei no Onmyouji: Tenen Jakko - Nishoku Kokkeiga",
"image": "https://cdn.myanimelist.net/images/manga/1/232292l.jpg"
},
{
"id": 98436,
"name": "The Saga of Tanya the Evil",
"image": "https://cdn.myanimelist.net/images/manga/3/186468l.jpg"
},
{
"id": 44347,
"name": "One-Punch Man",
"image": "https://cdn.myanimelist.net/images/manga/3/80661l.jpg"
}
]
},
{
"title": "Heartwarming & Wholesome",
"items": [
{
"id": 104,
"name": "Yotsuba&!",
"image": "https://cdn.myanimelist.net/images/manga/5/259524l.jpg"
},
{
"id": 13245,
"name": "Chihayafuru",
"image": "https://cdn.myanimelist.net/images/manga/1/245072l.jpg"
},
{
"id": 42451,
"name": "Horimiya",
"image": "https://cdn.myanimelist.net/images/manga/2/245008l.jpg"
},
{
"id": 35507,
"name": "Arcana Famiglia: Amore Mangiare Cantare!",
"image": "https://cdn.myanimelist.net/images/manga/1/81392l.jpg"
},
{
"id": 85802,
"name": "The boy who cried wolf tells a lie today also",
"image": "https://cdn.myanimelist.net/images/manga/3/148976l.jpg"
},
{
"id": 99007,
"name": "Komi Can't Communicate",
"image": "https://cdn.myanimelist.net/images/manga/3/188962l.jpg"
},
{
"id": 117060,
"name": "My Dress-Up Darling",
"image": "https://media.kitsu.app/manga/poster_images/41006/large.jpg"
},
{
"id": 96573,
"name": "Something About Us",
"image": "https://cdn.myanimelist.net/images/manga/3/189603l.jpg"
}
]
},
{
"title": "Love is in the Air",
"items": [
{
"id": 103851,
"name": "The Quintessential Quintuplets",
"image": "https://cdn.myanimelist.net/images/manga/2/201572l.jpg"
},
{
"id": 116539,
"name": "Destroy All Humans. They Can't Be Regenerated.",
"image": "https://cdn.myanimelist.net/images/manga/3/220187l.jpg"
},
{
"id": 14455,
"name": "Little Butterfly: Gush Limited",
"image": "https://cdn.myanimelist.net/images/manga/2/20679l.jpg"
},
{
"id": 128594,
"name": "One Piece: Ace's Story",
"image": "https://cdn.myanimelist.net/images/manga/2/266074l.jpg"
},
{
"id": 109229,
"name": "Yakuza Fianc\u00e9: Raise wa Tanin ga Ii",
"image": "https://cdn.myanimelist.net/images/manga/2/203176l.jpg"
},
{
"id": 56805,
"name": "A Silent Voice",
"image": "https://cdn.myanimelist.net/images/manga/1/120529l.jpg"
},
{
"id": 70851,
"name": "Futanari Musume ni Okasarechau!",
"image": "https://cdn.myanimelist.net/images/manga/1/121643l.jpg"
},
{
"id": 13,
"name": "One Piece",
"image": "https://cdn.myanimelist.net/images/manga/2/253146l.jpg"
}
]
},
{
"title": "Manhwa Masterpieces",
"items": [
{
"id": 121496,
"name": "Solo Leveling",
"image": "https://cdn.myanimelist.net/images/manga/3/222295l.jpg"
},
{
"id": 122663,
"name": "Tower of God",
"image": "https://cdn.myanimelist.net/images/manga/2/223694l.jpg"
},
{
"id": 8586,
"name": "The Breaker",
"image": "https://cdn.myanimelist.net/images/manga/2/270151l.jpg"
},
{
"id": 111213,
"name": "Bastard",
"image": "https://cdn.myanimelist.net/images/manga/1/205549l.jpg"
},
{
"id": 125036,
"name": "The Horizon",
"image": "https://cdn.myanimelist.net/images/manga/1/229331l.jpg"
},
{
"id": 121948,
"name": "Can I Have You All to Myself?",
"image": "https://cdn.myanimelist.net/images/manga/2/235219l.jpg"
},
{
"id": 110938,
"name": "Hoshi no Koe: The Voices of a Distant Star",
"image": "https://cdn.myanimelist.net/images/manga/2/240830l.jpg"
},
{
"id": 116966,
"name": "Hidoku Shinaide dj - Akakute Oishii",
"image": "https://cdn.myanimelist.net/images/manga/3/305350l.jpg"
}
]
},
{
"title": "Mind Games & Mysteries",
"items": [
{
"id": 3,
"name": "20th Century Boys",
"image": "https://cdn.myanimelist.net/images/manga/5/260006l.jpg"
},
{
"id": 7458,
"name": "Death Note Another Note: The Los Angeles BB Murder Cases",
"image": "https://cdn.myanimelist.net/images/manga/1/253578l.jpg"
},
{
"id": 10010,
"name": "Beelzebub",
"image": "https://cdn.myanimelist.net/images/manga/3/188942l.jpg"
},
{
"id": 108639,
"name": "Tsuiraku JK to Haijin Kyoushi",
"image": "https://cdn.myanimelist.net/images/manga/2/301288l.jpg"
},
{
"id": 393,
"name": "The Kindaichi Case Files",
"image": "https://cdn.myanimelist.net/images/manga/2/84823l.jpg"
},
{
"id": 139158,
"name": "Tomodachi Game",
"image": "https://cdn.myanimelist.net/images/manga/2/261962l.jpg"
},
{
"id": 13601,
"name": "Be Free!",
"image": "https://cdn.myanimelist.net/images/manga/4/76293l.jpg"
},
{
"id": 3573,
"name": "Tobaku Hakairoku Kaiji",
"image": "https://cdn.myanimelist.net/images/manga/1/302202l.jpg"
}
]
},
{
"title": "School Life & Sports",
"items": [
{
"id": 35243,
"name": "Haikyu!!",
"image": "https://cdn.myanimelist.net/images/manga/2/258225l.jpg"
},
{
"id": 30,
"name": "Ouran High School Host Club",
"image": "https://cdn.myanimelist.net/images/manga/3/267782l.jpg"
},
{
"id": 107931,
"name": "Blue Period",
"image": "https://cdn.myanimelist.net/images/manga/2/204827l.jpg"
},
{
"id": 150863,
"name": "Windbreak Samurai",
"image": "https://cdn.myanimelist.net/images/manga/2/296855l.jpg"
},
{
"id": 418,
"name": "Mushishi",
"image": "https://cdn.myanimelist.net/images/manga/2/159514l.jpg"
},
{
"id": 1061,
"name": "Case Closed",
"image": "https://cdn.myanimelist.net/images/manga/1/97267l.jpg"
},
{
"id": 11211,
"name": "Tenshi no Tame no Shohousen.",
"image": "https://cdn.myanimelist.net/images/manga/3/275687l.jpg"
},
{
"id": 78523,
"name": "ReLIFE",
"image": "https://cdn.myanimelist.net/images/manga/2/171573l.jpg"
}
]
}
]
}

240
animex/offline.html Normal file
View File

@@ -0,0 +1,240 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0, user-scalable=no">
<title>Offline - Media App</title>
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&display=swap" rel="stylesheet">
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.5.1/css/all.min.css">
<link rel="manifest" href="Resources/manifest.json/">
<meta name="theme-color" content="#0b0b0b" />
<link
rel="stylesheet"
href="https://site-assets.fontawesome.com/releases/v6.7.2/css/all.css"
>
<link
rel="stylesheet"
href="https://site-assets.fontawesome.com/releases/v6.7.2/css/sharp-solid.css"
>
<link
rel="stylesheet"
href="https://site-assets.fontawesome.com/releases/v6.7.2/css/sharp-regular.css"
>
<link
rel="stylesheet"
href="https://site-assets.fontawesome.com/releases/v6.7.2/css/sharp-light.css"
>
<link
rel="stylesheet"
href="https://site-assets.fontawesome.com/releases/v6.7.2/css/duotone.css"
/>
<link
rel="stylesheet"
href="https://site-assets.fontawesome.com/releases/v6.7.2/css/brands.css"
/>
<link rel="icon" href="/Resources/Images/icon-196.png" type="image/png">
<!-- iOS support -->
<link rel="apple-touch-icon" href="/Resources/Images/icon-196.png">
<style>
/* --- REDESIGNED UI WITH GLASSMORPHISM --- */
:root {
--bg-color: #121212;
--glass-bg: rgba(35, 35, 40, 0.6);
--glass-border: rgba(255, 255, 255, 0.1);
--blur-amount: 12px;
--border-radius-lg: 18px;
--border-radius-md: 12px;
--border-radius-sm: 8px;
--text-primary: #F5F5F7;
--text-secondary: #A1A1A6;
--accent-color: #FF9500;
--accent-color-hover: #ffae45;
--action-color: #FF9500; /* Changed for Retry button */
--action-color-hover: #ffae45;
--font-family: 'Inter', sans-serif;
}
/* --- Global & Base Styles --- */
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
body {
background-color: var(--bg-color);
color: var(--text-primary);
font-family: var(--font-family);
overflow-x: hidden;
position: relative;
display: flex;
align-items: center;
justify-content: center;
min-height: 100vh;
}
/* Decorative background blobs for depth */
body::before {
content: '';
position: fixed;
top: -20vh;
left: -20vw;
width: 60vw;
height: 60vw;
background: radial-gradient(circle, rgba(255, 149, 0, 0.2), transparent 70%);
filter: blur(100px);
z-index: -1;
animation: spin 30s linear infinite alternate;
}
body::after {
content: '';
position: fixed;
bottom: -30vh;
right: -30vw;
width: 80vw;
height: 80vw;
background: radial-gradient(circle, rgba(80, 20, 150, 0.15), transparent 70%);
filter: blur(120px);
z-index: -1;
animation: spin 40s linear infinite;
}
@keyframes spin {
from { transform: rotate(0deg) scale(1); }
to { transform: rotate(360deg) scale(1.1); }
}
.main-content {
max-width: 900px;
margin: 40px auto;
padding: 0 20px;
}
.section-title {
font-size: 1.8rem;
font-weight: 700;
margin-bottom: 24px;
}
/* --- Glass Pane Utility --- */
.glass-pane {
background: var(--glass-bg);
backdrop-filter: blur(var(--blur-amount));
-webkit-backdrop-filter: blur(var(--blur-amount));
border: 1px solid var(--glass-border);
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.25);
}
/* --- Offline Controls --- */
.offline-controls {
padding: 40px 20px;
border-radius: var(--border-radius-lg);
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 15px;
text-align: center;
}
.offline-icon {
font-size: 3rem;
color: var(--text-secondary);
margin-bottom: 20px;
}
.offline-message {
color: var(--text-secondary);
margin-top: -15px; /* Pull up closer to title */
margin-bottom: 25px;
max-width: 350px;
line-height: 1.5;
}
.button-container {
display: flex;
flex-wrap: wrap;
justify-content: center;
gap: 15px;
}
.action-button {
text-decoration: none; /* For <a> tag */
color: #000;
border: none;
padding: 12px 24px;
border-radius: var(--border-radius-sm);
font-size: 1em;
font-weight: 600;
cursor: pointer;
transition: background-color 0.2s ease-in-out, transform 0.1s ease;
display: inline-flex;
align-items: center;
gap: 10px;
}
.action-button:hover {
transform: translateY(-2px);
}
.action-button:active {
transform: translateY(0);
}
#retryButton {
background-color: var(--action-color);
color: var(--bg-color);
}
#retryButton:hover {
background-color: var(--action-color-hover);
}
#importButton {
background-color: var(--accent-color);
color: #000;
}
#importButton:hover {
background-color: var(--accent-color-hover);
}
/* --- Media Queries --- */
@media (max-width: 600px) {
.main-content { margin: 20px auto; padding: 0 15px; }
.section-title { font-size: 1.5rem; }
:root { --border-radius-lg: 16px; --border-radius-md: 10px; }
.offline-controls { padding: 30px 15px; }
}
</style>
</head>
<body>
<main class="main-content">
<div class="offline-controls glass-pane">
<i class="fas fa-wifi-slash offline-icon"></i>
<h2 class="section-title">No Internet Connection</h2>
<p class="offline-message">
You appear to be offline. You can retry the connection or access your offline library.
</p>
<div class="button-container">
<button id="retryButton" class="action-button" onclick="window.location.reload()">
<i class="fas fa-rotate-right"></i>
Retry
</button>
<a href="library.html" id="importButton" class="action-button">
<i class="fas fa-folder-open"></i>
Go to Library
</a>
</div>
</div>
</main>
<!-- No script tag needed for this static page -->
</body>
</html>

503
animex/pdf.html Normal file
View File

@@ -0,0 +1,503 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Animex PDF Reader</title>
<!-- PDF.js Library from CDN -->
<script src="https://cdnjs.cloudflare.com/ajax/libs/pdf.js/3.11.174/pdf.min.js"></script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/pdf.js/3.11.174/pdf.worker.min.js"></script>
<!-- Font Awesome for Icons -->
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.2.1/css/all.min.css">
<style>
:root {
--primary-bg: #121212;
--secondary-bg: rgba(30, 30, 30, 0.9);
--text-color: #e0e0e0;
--accent-color: #FF9500;
--border-color: #333333;
--shadow-color: rgba(0, 0, 0, 0.5);
--icon-fill: #e0e0e0;
--header-height: 55px;
--footer-height: 60px;
}
html, body {
margin: 0; padding: 0; width: 100%; height: 100%;
background-color: var(--primary-bg); color: var(--text-color);
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif;
overflow: hidden;
}
#app-container { display: flex; flex-direction: column; height: 100vh; width: 100vw; position: relative; }
/* --- Welcome/Message Screen --- */
#message-container {
position: fixed; top: 0; left: 0; width: 100%; height: 100%;
display: flex; justify-content: center; align-items: center;
background-color: var(--primary-bg); z-index: 100;
flex-direction: column; padding: 20px; box-sizing: border-box; text-align: center;
}
.loader {
border: 5px solid var(--border-color); border-top: 5px solid var(--accent-color);
border-radius: 50%; width: 50px; height: 50px;
animation: spin 1s linear infinite; margin-bottom: 20px;
}
@keyframes spin { 0% { transform: rotate(0deg); } 100% { transform: rotate(360deg); } }
#file-input-button {
background-color: var(--accent-color); color: #121212; border: none; padding: 12px 24px;
border-radius: 8px; font-size: 16px; font-weight: 600; cursor: pointer; display: none; margin-top: 15px;
transition: transform 0.2s ease, background-color 0.2s ease;
}
#file-input-button:hover { background-color: #ffae42; transform: scale(1.05); }
#file-input { display: none; }
/* --- Viewer --- */
#viewer-container {
flex-grow: 1; position: relative; overflow: auto;
display: flex; justify-content: center;
padding-top: calc(var(--header-height) + 20px);
padding-bottom: calc(var(--footer-height) + 20px);
box-sizing: border-box;
}
#pdf-container canvas {
display: block; margin: 0 auto;
max-width: 100%; height: auto;
transition: box-shadow 0.3s ease;
}
#pdf-container.paged-view canvas {
margin-bottom: 30px;
box-shadow: 0 8px 25px var(--shadow-color);
}
#pdf-container.webtoon-view canvas {
margin-bottom: 0px;
}
/* --- Controls --- */
.controls-bar {
position: fixed; left: 0; width: 100%;
background-color: var(--secondary-bg);
backdrop-filter: blur(10px); -webkit-backdrop-filter: blur(10px);
box-shadow: 0 0 15px var(--shadow-color);
display: flex; justify-content: space-between; align-items: center;
padding: 0 20px; box-sizing: border-box; z-index: 20;
transition: transform 0.3s ease-in-out;
-webkit-tap-highlight-color: transparent;
}
#header { top: 0; height: var(--header-height); border-bottom: 1px solid var(--border-color);}
#footer { bottom: 0; height: var(--footer-height); border-top: 1px solid var(--border-color);}
.controls-hidden #header { transform: translateY(-100%); }
.controls-hidden #footer { transform: translateY(100%); }
.control-group { display: flex; align-items: center; gap: 8px; }
.control-button {
background: none; border: none; color: var(--icon-fill);
cursor: pointer; padding: 10px; border-radius: 50%;
width: 44px; height: 44px; display: flex; justify-content: center; align-items: center;
font-size: 18px; transition: background-color 0.2s ease, color 0.2s ease;
}
.control-button:hover:not(:disabled) { background-color: rgba(255, 255, 255, 0.1); }
.control-button:disabled { color: #666; cursor: not-allowed; }
.control-button.active { color: var(--accent-color); background-color: rgba(255, 149, 0, 0.15); }
#file-name {
font-size: 16px; white-space: nowrap; overflow: hidden;
text-overflow: ellipsis; max-width: calc(100vw - 400px);
}
#series-title, #chapter-title {
max-width: calc(100vw - 400px);
}
#page-info { font-size: 16px; min-width: 90px; text-align: center; cursor: pointer; padding: 8px 12px; border-radius: 20px; transition: background-color 0.2s ease; user-select: none; }
#page-info:hover { background-color: rgba(255, 255, 255, 0.1); }
#page-input { width: 50px; background-color: var(--primary-bg); color: var(--text-color); border: 1px solid var(--accent-color); text-align: center; font-size: 16px; border-radius: 4px; }
.divider { width: 1px; height: 25px; background-color: var(--border-color); margin: 0 10px; }
@media (max-width: 600px) {
#series-title, #chapter-title { max-width: calc(100vw - 250px); }
#file-name { display: none; }
.divider:not(.mobile-visible) { display: none; }
.control-group { gap: 5px; }
.controls-bar { padding: 0 10px; }
}
</style>
</head>
<body>
<div id="app-container">
<!-- Initial Message/Loader -->
<div id="message-container">
<div id="loader" class="loader"></div>
<p id="message-text">Loading...</p>
<input type="file" id="file-input" accept="application/pdf" />
<button id="file-input-button">Choose a Local PDF</button>
</div>
<!-- PDF Viewer Area -->
<div id="viewer-container">
<div id="pdf-container"></div>
</div>
<!-- Top Controls -->
<header id="header" class="controls-bar">
<div class="control-group">
<!-- <button id="back-button" class="control-button" title="Back" style="display: none;"><i class="fa-solid fa-arrow-left"></i></button> -->
<div style="display: flex; flex-direction: column; align-items: flex-start; margin-left: 10px;">
<span id="series-title" style="font-size: 1em; font-weight: bold; white-space: nowrap; overflow: hidden; text-overflow: ellipsis;"></span>
<span id="chapter-title" style="font-size: 0.8em; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; opacity: 0.8;"></span>
</div>
</div>
<div class="control-group">
<button id="view-webtoon-btn" class="control-button" title="Webtoon View"><i class="fa-solid fa-arrows-up-down"></i></button>
<button id="view-paged-btn" class="control-button" title="Paged View"><i class="fa-solid fa-file-lines"></i></button>
<div class="divider"></div>
<button id="rotate-button" class="control-button" title="Rotate Clockwise"><i class="fa-solid fa-rotate-right"></i></button>
</div>
</header>
<!-- Bottom Controls -->
<footer id="footer" class="controls-bar">
<div class="control-group">
<button id="zoom-out-button" class="control-button" title="Zoom Out"><i class="fa-solid fa-magnifying-glass-minus"></i></button>
<button id="zoom-fit-width-button" class="control-button" title="Fit to Width"><i class="fa-solid fa-arrows-left-right-to-line"></i></button>
<button id="zoom-fit-page-button" class="control-button" title="Fit to Page"><i class="fa-solid fa-up-right-and-down-left-from-center"></i></button>
<button id="zoom-in-button" class="control-button" title="Zoom In"><i class="fa-solid fa-magnifying-glass-plus"></i></button>
</div>
<div id="page-controls" class="control-group">
<span id="page-info">0 / 0</span>
</div>
<div id="progress-bar-container" style="display: none; width: 200px; height: 4px; background-color: var(--border-color); border-radius: 2px; overflow: hidden;">
<div id="progress-bar" style="width: 0%; height: 100%; background-color: var(--accent-color);"></div>
</div>
<div class="control-group" style="min-width: 176px; justify-content: flex-end;">
<button id="next-chapter-button" class="control-button" title="Next Chapter" style="display: none;"><i class="fa-solid fa-step-forward"></i></button>
</div>
</footer>
</div>
<script>
pdfjsLib.GlobalWorkerOptions.workerSrc = `https://cdnjs.cloudflare.com/ajax/libs/pdf.js/3.11.174/pdf.worker.min.js`;
// State variables
let pdfDoc = null, currentPageNum = 1, totalPages = 0;
let currentScale = 'auto', currentRotation = 0, currentMode = 'paged';
let isRendering = false, pdfUrl = null, autoHideTimeout = null;
let touchStartX = 0, touchMoveX = 0;
// DOM elements
const appContainer = document.getElementById('app-container');
const viewerContainer = document.getElementById('viewer-container');
const pdfContainer = document.getElementById('pdf-container');
const pageInfo = document.getElementById('page-info');
const messageContainer = document.getElementById('message-container');
const loader = document.getElementById('loader');
const messageText = document.getElementById('message-text');
const fileInput = document.getElementById('file-input');
const fileInputButton = document.getElementById('file-input-button');
const seriesTitleEl = document.getElementById('series-title');
const chapterTitleEl = document.getElementById('chapter-title');
// --- PDF Loading and Rendering ---
async function loadAndRenderPdf(source, seriesTitle, chapterTitle) {
try {
showLoadingState(`Loading PDF...`);
pdfUrl = source;
const loadingTask = pdfjsLib.getDocument(source);
pdfDoc = await loadingTask.promise;
totalPages = pdfDoc.numPages;
currentPageNum = 1;
currentRotation = 0;
seriesTitleEl.textContent = seriesTitle || 'Document';
chapterTitleEl.textContent = chapterTitle || '';
document.title = seriesTitle ? `${seriesTitle} - ${chapterTitle} - Reader` : 'PDF Reader';
messageContainer.style.display = 'none';
appContainer.classList.remove('controls-hidden');
await detectAndSetLayout(); // Smart layout detection
setupEventListeners();
showControls();
} catch (error) {
console.error("Error loading PDF:", error);
showErrorState(`Error: Could not load PDF. Check file/URL.`);
}
}
// Smart feature from Animex
async function detectAndSetLayout() {
const page = await pdfDoc.getPage(1);
const viewport = page.getViewport({ scale: 1 });
const isWebtoon = viewport.height > viewport.width * 2;
setViewMode(isWebtoon ? 'webtoon' : 'paged', true); // Set mode without re-rendering yet
await updateView(); // Now render with the correct mode
}
async function renderPage(num) {
if (isRendering) return;
isRendering = true;
try {
const page = await pdfDoc.getPage(num);
const scale = await calculateScale();
const viewport = page.getViewport({ scale, rotation: currentRotation });
const canvasId = `page-${num}`;
let canvas = document.getElementById(canvasId);
if (!canvas) { return; }
canvas.height = viewport.height;
canvas.width = viewport.width;
await page.render({ canvasContext: canvas.getContext('2d'), viewport }).promise;
canvas.dataset.rendered = "true";
} finally {
isRendering = false;
}
}
async function updateView() {
if (!pdfDoc) return;
pdfContainer.innerHTML = '';
viewerContainer.scrollTop = 0;
if (currentMode === 'paged') {
viewerContainer.style.overflow = 'hidden';
const canvas = document.createElement('canvas');
canvas.id = `page-${currentPageNum}`;
pdfContainer.appendChild(canvas);
await renderPage(currentPageNum);
} else { // webtoon mode
viewerContainer.style.overflow = 'auto';
for (let i = 1; i <= totalPages; i++) {
const canvas = document.createElement('canvas');
canvas.id = `page-${i}`;
pdfContainer.appendChild(canvas);
}
lazyLoadVisiblePages();
}
updateControls();
}
async function calculateScale() {
const page = await pdfDoc.getPage(1);
const viewport = page.getViewport({ scale: 1.0, rotation: currentRotation });
const containerWidth = viewerContainer.clientWidth - 30;
const containerHeight = viewerContainer.clientHeight - 30;
if (currentScale === 'auto') {
return Math.min(containerWidth / viewport.width, containerHeight / viewport.height);
} else if (currentScale === 'width') {
return containerWidth / viewport.width;
}
return currentScale; // It's a number
}
// --- UI and Controls ---
function updateControls() {
pageInfo.textContent = `PG ${currentPageNum} / ${totalPages}`;
document.getElementById('view-paged-btn').classList.toggle('active', currentMode === 'paged');
document.getElementById('view-webtoon-btn').classList.toggle('active', currentMode === 'webtoon');
['zoom-fit-page-button', 'zoom-fit-width-button'].forEach(id => document.getElementById(id).classList.remove('active'));
if(currentScale === 'auto') document.getElementById('zoom-fit-page-button').classList.add('active');
if(currentScale === 'width') document.getElementById('zoom-fit-width-button').classList.add('active');
}
function showLoadingState(message) {
messageContainer.style.display = 'flex';
loader.style.display = 'block';
fileInputButton.style.display = 'none';
messageText.textContent = message;
}
function showErrorState(message) {
loader.style.display = 'none';
fileInputButton.style.display = 'block';
messageText.innerHTML = message;
}
function showControls() {
clearTimeout(autoHideTimeout);
appContainer.classList.remove('controls-hidden');
autoHideTimeout = setTimeout(() => {
if(!document.querySelector('#page-input')) appContainer.classList.add('controls-hidden');
}, 3000);
}
// --- Event Handlers ---
function goToPrevPage() { if (currentPageNum > 1) { currentPageNum--; updateView(); } }
function goToNextPage() { if (currentPageNum < totalPages) { currentPageNum++; updateView(); } }
function goToPage(num) {
const pageNumber = parseInt(num);
if (pageNumber > 0 && pageNumber <= totalPages) {
currentPageNum = pageNumber;
if (currentMode === 'paged') {
updateView();
} else {
document.getElementById(`page-${currentPageNum}`)?.scrollIntoView({ behavior: 'smooth', block: 'start' });
}
}
}
function setZoom(type) {
if (type === 'in') currentScale = (typeof currentScale !== 'number' ? 1.0 : currentScale) + 0.2;
else if (type === 'out') currentScale = Math.max(0.2, (typeof currentScale !== 'number' ? 1.0 : currentScale) - 0.2);
else currentScale = type;
updateView();
}
function rotate() { currentRotation = (currentRotation + 90) % 360; updateView(); }
function setViewMode(mode, isInitial = false) {
if (currentMode === mode && !isInitial) return;
currentMode = mode;
pdfContainer.className = `${mode}-view`;
const pageControls = document.getElementById('page-controls');
const progressBar = document.getElementById('progress-bar-container');
if (mode === 'webtoon') {
pageControls.style.display = 'none';
progressBar.style.display = 'block';
} else {
pageControls.style.display = 'flex';
progressBar.style.display = 'none';
}
if (!isInitial) updateView(); // Only re-render if it's a manual switch
}
// --- Lazy Loading for Webtoon Mode ---
const observer = new IntersectionObserver((entries) => {
entries.forEach(entry => {
if (entry.isIntersecting) {
const canvas = entry.target;
const pageNum = parseInt(canvas.id.split('-')[1]);
if (entry.intersectionRatio > 0.5) {
if (currentPageNum !== pageNum) {
currentPageNum = pageNum;
updateControls();
}
}
if (canvas.dataset.rendered !== "true") renderPage(pageNum);
}
});
}, { root: viewerContainer, threshold: [0.1, 0.5, 0.9] });
function lazyLoadVisiblePages() {
observer.disconnect();
pdfContainer.querySelectorAll('canvas').forEach(canvas => observer.observe(canvas));
}
// --- Setup and Initialization ---
function setupEventListeners() {
if (setupEventListeners.bound) return;
setupEventListeners.bound = true;
// Header/Footer Controls
document.getElementById('view-webtoon-btn').addEventListener('click', () => setViewMode('webtoon'));
document.getElementById('view-paged-btn').addEventListener('click', () => setViewMode('paged'));
document.getElementById('rotate-button').addEventListener('click', rotate);
document.getElementById('zoom-in-button').addEventListener('click', () => setZoom('in'));
document.getElementById('zoom-out-button').addEventListener('click', () => setZoom('out'));
document.getElementById('zoom-fit-page-button').addEventListener('click', () => setZoom('auto'));
document.getElementById('zoom-fit-width-button').addEventListener('click', () => setZoom('width'));
pageInfo.addEventListener('click', () => {
pageInfo.innerHTML = `<input id="page-input" type="number" min="1" max="${totalPages}" value="${currentPageNum}" />`;
const pageInput = document.getElementById('page-input');
pageInput.focus(); pageInput.select();
const revert = () => { if (document.getElementById('page-input')) updateControls(); };
pageInput.addEventListener('blur', revert);
pageInput.addEventListener('keydown', (e) => {
if (e.key === 'Enter') { goToPage(pageInput.value); pageInput.blur(); }
if (e.key === 'Escape') pageInput.blur();
});
});
window.addEventListener('keydown', (e) => {
if (e.target.tagName === 'INPUT') return;
if (e.key === 'ArrowLeft') goToPrevPage();
else if (e.key === 'ArrowRight') goToNextPage();
});
['mousemove', 'mousedown', 'touchstart'].forEach(evt => window.addEventListener(evt, showControls));
viewerContainer.addEventListener('touchstart', (e) => { if(currentMode === 'paged') { touchStartX = e.changedTouches[0].screenX; touchMoveX = touchStartX; } }, { passive: true });
viewerContainer.addEventListener('touchmove', (e) => { if(currentMode === 'paged') { touchMoveX = e.changedTouches[0].screenX; } }, { passive: true });
viewerContainer.addEventListener('touchend', () => { if(currentMode === 'paged') { if (touchStartX - touchMoveX > 50) goToNextPage(); else if (touchMoveX - touchStartX > 50) goToPrevPage(); } });
viewerContainer.addEventListener('scroll', () => {
if (currentMode === 'webtoon') {
const { scrollTop, scrollHeight, clientHeight } = viewerContainer;
const scrollPercent = (scrollTop / (scrollHeight - clientHeight)) * 100;
document.getElementById('progress-bar').style.width = `${scrollPercent}%`;
}
});
}
window.addEventListener('load', () => {
const params = new URLSearchParams(window.location.search);
const fileUrlParam = params.get('file');
const seriesTitleParam = params.get('seriesTitle');
const chapterTitleParam = params.get('chapterTitle');
const isEmbedded = params.get('embedded');
const seriesIdParam = params.get('seriesId');
const typeParam = params.get('type');
const nextItemNumParam = params.get('nextItemNum');
if (seriesIdParam && typeParam && nextItemNumParam) {
const nextChapterButton = document.getElementById('next-chapter-button');
nextChapterButton.style.display = 'flex';
nextChapterButton.addEventListener('click', () => {
window.parent.postMessage({
type: 'pdf-reader-next',
seriesId: seriesIdParam,
type: typeParam,
itemNum: nextItemNumParam
}, '*');
});
}
if (isEmbedded) {
const backButton = document.getElementById('back-button');
backButton.style.display = 'flex';
backButton.addEventListener('click', () => {
window.parent.postMessage({ type: 'pdf-reader-back' }, '*');
});
// Listen for the PDF data from the parent
window.addEventListener('message', (event) => {
if (event.data && event.data.type === 'load-pdf') {
const { fileData, seriesTitle, chapterTitle } = event.data;
// Create a URL that is valid in *this* document's context
const localPdfUrl = URL.createObjectURL(fileData);
loadAndRenderPdf(localPdfUrl, seriesTitle, chapterTitle);
}
});
// Tell the parent that this iframe is ready to receive the file
window.parent.postMessage({ type: 'pdf-reader-ready' }, '*');
} else if (fileUrlParam) {
loadAndRenderPdf(fileUrlParam, decodeURIComponent(seriesTitleParam || ''), decodeURIComponent(chapterTitleParam || ''));
} else {
showErrorState(`Provide a PDF via the <code>?file=...</code> URL parameter, or choose one locally.`);
}
fileInputButton.addEventListener('click', () => fileInput.click());
fileInput.addEventListener('change', (event) => {
const file = event.target.files[0];
if (file?.type === 'application/pdf') {
loadAndRenderPdf(URL.createObjectURL(file), file.name, '');
}
});
});
</script>
</body>
</html>

225
animex/portal.html Normal file
View File

@@ -0,0 +1,225 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Hub</title>
<!-- Google Fonts: Inter -->
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;600;800&display=swap" rel="stylesheet">
<!-- Font Awesome -->
<link
rel="stylesheet"
href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.5.2/css/all.min.css"
/>
<style>
:root {
--accent-color: #ff9500;
--accent-glow: rgba(255, 149, 0, 0.5);
--bg-dark: #0a0a0a;
--bg-light: #141414;
--text-main: #ffffff;
--text-muted: #888888;
--card-bg: rgba(30, 30, 30, 0.6);
--card-border: rgba(255, 255, 255, 0.08);
}
body {
margin: 0;
font-family: 'Inter', sans-serif;
background-color: var(--bg-dark);
/* Subtle radial gradient for depth */
background-image: radial-gradient(circle at center, var(--bg-light) 0%, var(--bg-dark) 100%);
color: var(--text-main);
display: flex;
justify-content: center;
align-items: center;
min-height: 100vh;
flex-direction: column;
overflow: hidden; /* Prevents scrollbars during hover animations */
}
/* Background decorative glow behind everything */
body::before {
content: '';
position: absolute;
width: 600px;
height: 600px;
background: var(--accent-color);
opacity: 0.03;
filter: blur(100px);
border-radius: 50%;
z-index: -1;
pointer-events: none;
}
header {
z-index: 10;
margin-bottom: 40px;
text-align: center;
animation: fadeInDown 0.8s ease-out;
}
.logo {
font-size: 4em;
font-weight: 800;
cursor: pointer;
letter-spacing: -2px;
line-height: 1;
transition: transform 0.3s ease;
text-shadow: 0 10px 30px rgba(0,0,0,0.5);
}
.logo:hover {
transform: scale(1.02);
}
.logo span {
color: var(--accent-color);
display: inline-block;
}
.tagline {
margin-top: 10px;
color: var(--text-muted);
font-size: 0.9em;
font-weight: 400;
letter-spacing: 1px;
text-transform: uppercase;
}
.hub-container {
display: flex;
gap: 24px;
padding: 20px;
z-index: 10;
animation: fadeInUp 0.8s ease-out 0.2s backwards;
}
.hub-card {
width: 160px;
height: 160px;
/* Glassmorphism settings */
background: var(--card-bg);
backdrop-filter: blur(12px);
-webkit-backdrop-filter: blur(12px);
border: 1px solid var(--card-border);
border-radius: 24px;
display: flex;
flex-direction: column;
justify-content: center;
align-items: center;
text-decoration: none;
color: var(--text-main);
position: relative;
transition: all 0.4s cubic-bezier(0.175, 0.885, 0.32, 1.275);
box-shadow: 0 10px 30px rgba(0,0,0,0.3);
}
.hub-icon {
font-size: 42px;
margin-bottom: 16px;
color: var(--text-main);
transition: transform 0.4s ease, color 0.3s ease;
z-index: 2;
}
.hub-text {
font-size: 1.1em;
font-weight: 600;
letter-spacing: 0.5px;
z-index: 2;
color: var(--text-muted);
transition: color 0.3s ease;
}
/* Hover Effects */
.hub-card:hover {
transform: translateY(-8px);
border-color: rgba(255, 149, 0, 0.3);
box-shadow: 0 20px 40px rgba(0,0,0,0.4),
0 0 30px var(--accent-glow); /* Outer glow */
}
.hub-card:hover .hub-icon {
color: var(--accent-color);
transform: scale(1.1);
}
.hub-card:hover .hub-text {
color: var(--text-main);
}
/* Subtle inner highlight on hover */
.hub-card::after {
content: '';
position: absolute;
top: 0; left: 0; right: 0; bottom: 0;
border-radius: 24px;
background: radial-gradient(circle at top, rgba(255,255,255,0.1), transparent 70%);
opacity: 0;
transition: opacity 0.3s ease;
}
.hub-card:hover::after {
opacity: 1;
}
/* Animations */
@keyframes fadeInDown {
from { opacity: 0; transform: translateY(-20px); }
to { opacity: 1; transform: translateY(0); }
}
@keyframes fadeInUp {
from { opacity: 0; transform: translateY(20px); }
to { opacity: 1; transform: translateY(0); }
}
@media (max-width: 500px) {
.hub-container {
flex-direction: column;
gap: 16px;
}
.hub-card {
width: 100%;
min-width: 140px;
height: 140px;
flex-direction: column; /* Horizontal layout on mobile looks better */
gap: 20px;
}
.hub-icon {
margin-bottom: 0;
font-size: 32px;
}
}
</style>
</head>
<body>
<header>
<div class="logo" onclick="window.location.href='/'">
Sinimex<span>.</span>
</div>
<div class="tagline">Media Hub</div>
</header>
<div class="hub-container">
<!-- Feed Card -->
<a href="https://feeder-main.vercel.app/feed" class="hub-card">
<i class="fa-solid fa-circle-play hub-icon"></i>
<div class="hub-text">Feed</div>
</a>
<!-- Read Card -->
<a href="/ext/nsfw/hentai" class="hub-card">
<i class="fa-solid fa-book-open hub-icon"></i>
<div class="hub-text">Read</div>
</a>
</div>
</body>
</html>

486
animex/reader.html Normal file
View File

@@ -0,0 +1,486 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Animex - Modern Manga Reader</title>
<!-- Google Fonts & Font Awesome -->
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&display=swap" rel="stylesheet">
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.2.1/css/all.min.css">
<style>
:root {
--bg-dark: #121212;
--bg-panel: #1E1E1E;
--text-light: #EAEAEA;
--text-muted: #8A8A8A;
--accent: #FF9500;
--border-color: #333333;
--shadow-color: rgba(0, 0, 0, 0.5);
--border-radius: 12px;
--transition-speed: 0.2s;
}
* {
box-sizing: border-box;
margin: 0;
padding: 0;
}
html, body {
height: 100vh;
width: 100vw;
overflow: hidden;
background: radial-gradient(circle at top left, #2a2a2d, var(--bg-dark));
color: var(--text-light);
font-family: 'Inter', -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif;
font-synthesis: none;
text-rendering: optimizeLegibility;
}
/* --- Main Layout --- */
#app-container {
display: flex;
height: 100%;
}
#left-panel {
flex: 0 0 25%;
max-width: 380px;
min-width: 280px;
background-color: var(--bg-panel);
border-right: 1px solid var(--border-color);
display: flex;
flex-direction: column;
box-shadow: 5px 0px 25px var(--shadow-color);
z-index: 10;
}
#right-panel {
flex: 1;
display: flex;
flex-direction: column;
}
/* --- Left Panel: Library --- */
#library-header {
padding: 24px;
flex-shrink: 0;
}
#library-header h1 {
font-size: 28px;
font-weight: 700;
letter-spacing: 1px;
background: linear-gradient(45deg, var(--accent), #ffbf66);
-webkit-background-clip: text;
-webkit-text-fill-color: transparent;
}
#import-btn {
display: flex;
align-items: center;
justify-content: center;
gap: 12px;
width: calc(100% - 48px);
margin: 0 24px 24px 24px;
padding: 14px;
background-color: var(--accent);
color: #000;
font-weight: 600;
font-size: 16px;
border: none;
border-radius: 8px;
cursor: pointer;
transition: all var(--transition-speed) ease;
box-shadow: 0 4px 15px rgba(0, 0, 0, 0.2);
}
#import-btn:hover {
transform: translateY(-2px);
box-shadow: 0 6px 20px rgba(255, 149, 0, 0.3);
background-color: #ffae42;
}
#file-list-container {
flex: 1;
overflow-y: auto;
padding: 0 10px;
}
#file-list {
list-style: none;
}
#file-list li {
padding: 16px 14px;
border-radius: 8px;
cursor: pointer;
transition: background-color var(--transition-speed) ease;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
border-left: 4px solid transparent;
margin-bottom: 4px;
}
#file-list li:hover {
background-color: rgba(255, 255, 255, 0.05);
}
#file-list li.active {
background-color: rgba(255, 149, 0, 0.1);
color: var(--accent);
font-weight: 500;
border-left: 4px solid var(--accent);
}
/* --- Right Panel: Viewer --- */
#viewer-header {
display: flex;
justify-content: space-between;
align-items: center;
padding: 16px 30px;
background-color: rgba(30, 30, 30, 0.8);
backdrop-filter: blur(10px);
border-bottom: 1px solid var(--border-color);
flex-shrink: 0;
z-index: 5;
}
#document-title {
font-size: 18px;
font-weight: 500;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
margin-right: 20px;
}
#viewer-controls {
display: flex;
align-items: center;
gap: 12px;
}
.control-btn {
background-color: rgba(255,255,255,0.08);
border: none;
color: var(--text-light);
width: 40px;
height: 40px;
border-radius: 50%;
cursor: pointer;
transition: all var(--transition-speed) ease;
display: grid;
place-items: center;
font-size: 16px;
}
.control-btn:hover {
background-color: rgba(255,255,255,0.15);
transform: scale(1.1);
}
.control-btn.active {
background-color: var(--accent);
color: #000;
}
#zoom-controls { display: flex; align-items: center; gap: 8px; }
#zoom-level {
padding: 8px 16px;
font-size: 14px;
font-weight: 500;
color: var(--text-muted);
background-color: rgba(0,0,0,0.2);
border-radius: 20px;
min-width: 70px;
text-align: center;
}
#pdf-viewer-container {
flex: 1;
overflow: auto;
text-align: center;
padding: 40px 0;
}
#pdf-viewer {
display: flex;
flex-direction: column;
align-items: center;
gap: 4px;
}
#pdf-viewer.paged-view {
gap: 40px;
}
.pdf-page {
max-width: 100%;
height: auto;
border-radius: 4px;
}
#pdf-viewer.paged-view .pdf-page {
box-shadow: 0 15px 35px var(--shadow-color);
}
#placeholder {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
height: 100%;
color: var(--text-muted);
user-select: none;
}
#placeholder .fa-solid {
font-size: 80px;
margin-bottom: 24px;
opacity: 0.3;
}
/* --- Custom Scrollbar --- */
::-webkit-scrollbar { width: 12px; }
::-webkit-scrollbar-track { background: var(--bg-panel); }
::-webkit-scrollbar-thumb {
background-color: #444;
border-radius: 10px;
border: 3px solid var(--bg-panel);
}
::-webkit-scrollbar-thumb:hover { background-color: var(--accent); }
</style>
</head>
<body>
<div id="app-container">
<!-- LEFT PANEL -->
<aside id="left-panel">
<header id="library-header">
<h1>Animex</h1>
</header>
<button id="import-btn">
<i class="fa-solid fa-folder-open"></i>
<span>Import Folder</span>
</button>
<div id="file-list-container">
<ul id="file-list">
<!-- Files will be dynamically added here -->
</ul>
</div>
</aside>
<!-- RIGHT PANEL -->
<main id="right-panel">
<header id="viewer-header">
<h2 id="document-title">No file selected</h2>
<div id="viewer-controls">
<button id="view-webtoon-btn" class="control-btn" title="Webtoon View (Continuous Scroll)">
<i class="fa-solid fa-arrows-up-down"></i>
</button>
<button id="view-paged-btn" class="control-btn" title="Paged View">
<i class="fa-solid fa-file-lines"></i>
</button>
<div id="zoom-controls">
<button id="zoom-out-btn" class="control-btn" title="Zoom Out"><i class="fa-solid fa-magnifying-glass-minus"></i></button>
<span id="zoom-level">100%</span>
<button id="zoom-in-btn" class="control-btn" title="Zoom In"><i class="fa-solid fa-magnifying-glass-plus"></i></button>
</div>
</div>
</header>
<div id="pdf-viewer-container">
<div id="placeholder">
<i class="fa-solid fa-eye"></i>
<h2>Select a folder to start reading</h2>
<p>Your files are processed locally and never uploaded.</p>
</div>
<div id="pdf-viewer">
<!-- PDF pages (canvases) will be rendered here -->
</div>
</div>
</main>
</div>
<!-- PDF.js Library -->
<script src="https://cdnjs.cloudflare.com/ajax/libs/pdf.js/2.12.313/pdf.min.js"></script>
<script>
pdfjsLib.GlobalWorkerOptions.workerSrc = `https://cdnjs.cloudflare.com/ajax/libs/pdf.js/2.12.313/pdf.worker.min.js`;
const importBtn = document.getElementById('import-btn');
const fileList = document.getElementById('file-list');
const documentTitle = document.getElementById('document-title');
const pdfViewer = document.getElementById('pdf-viewer');
const placeholder = document.getElementById('placeholder');
const pdfViewerContainer = document.getElementById('pdf-viewer-container');
const webtoonViewBtn = document.getElementById('view-webtoon-btn');
const pagedViewBtn = document.getElementById('view-paged-btn');
const zoomOutBtn = document.getElementById('zoom-out-btn');
const zoomInBtn = document.getElementById('zoom-in-btn');
const zoomLevelDisplay = document.getElementById('zoom-level');
let fileHandles = [];
let currentPdf = null;
let currentFileHandle = null;
let currentPageNum = 1;
let zoomLevel = 1.0;
let viewMode = 'webtoon';
importBtn.addEventListener('click', handleImportFolder);
fileList.addEventListener('click', (e) => {
const li = e.target.closest('li');
if (li) {
const index = parseInt(li.dataset.index, 10);
const handle = fileHandles[index];
if (handle && handle !== currentFileHandle) {
loadPdf(handle, index);
}
}
});
webtoonViewBtn.addEventListener('click', () => setViewMode('webtoon'));
pagedViewBtn.addEventListener('click', () => setViewMode('paged'));
zoomInBtn.addEventListener('click', () => changeZoom(0.1));
zoomOutBtn.addEventListener('click', () => changeZoom(-0.1));
window.addEventListener('keydown', handleKeyPress);
async function handleImportFolder() {
try {
const dirHandle = await window.showDirectoryPicker();
fileHandles = [];
for await (const entry of dirHandle.values()) {
if (entry.kind === 'file' && entry.name.toLowerCase().endsWith('.pdf')) {
fileHandles.push(entry);
}
}
fileHandles.sort((a, b) => a.name.localeCompare(b.name, undefined, { numeric: true }));
populateFileList();
if (fileHandles.length > 0) {
loadPdf(fileHandles[0], 0);
}
} catch (err) {
if (err.name !== 'AbortError') {
console.error("Error importing folder:", err);
alert('Could not read directory. Please check permissions.');
}
}
}
function populateFileList() {
fileList.innerHTML = '';
if (fileHandles.length === 0) {
fileList.innerHTML = '<li>No PDF files found.</li>';
return;
}
fileHandles.forEach((handle, index) => {
const li = document.createElement('li');
li.textContent = handle.name.replace(/\.pdf$/i, '');
li.dataset.index = index;
fileList.appendChild(li);
});
}
async function loadPdf(fileHandle, index) {
currentFileHandle = fileHandle;
placeholder.style.display = 'none';
pdfViewer.innerHTML = `<h2 style="color: var(--text-muted)">Loading ${fileHandle.name}...</h2>`;
pdfViewerContainer.scrollTop = 0;
document.querySelectorAll('#file-list li').forEach(li => li.classList.remove('active'));
document.querySelector(`#file-list li[data-index="${index}"]`).classList.add('active');
documentTitle.textContent = fileHandle.name.replace(/\.pdf$/i, '');
try {
const file = await fileHandle.getFile();
const fileReader = new FileReader();
fileReader.onload = async (e) => {
const typedarray = new Uint8Array(e.target.result);
const pdf = await pdfjsLib.getDocument(typedarray).promise;
currentPdf = pdf;
currentPageNum = 1;
await detectAndSetLayout();
await renderPdf();
};
fileReader.readAsArrayBuffer(file);
} catch (err) {
console.error("Error loading PDF:", err);
pdfViewer.innerHTML = '<h2>Error loading file.</h2>';
}
}
async function detectAndSetLayout() {
if (!currentPdf) return;
const page = await currentPdf.getPage(1);
const viewport = page.getViewport({ scale: 1 });
setViewMode(viewport.height > viewport.width * 2 ? 'webtoon' : 'paged');
}
async function renderPdf() {
if (!currentPdf) return;
pdfViewer.innerHTML = '';
for (let i = 1; i <= currentPdf.numPages; i++) {
const page = await currentPdf.getPage(i);
const viewport = page.getViewport({ scale: zoomLevel });
const canvas = document.createElement('canvas');
canvas.className = 'pdf-page';
canvas.id = `page-${i}`;
const context = canvas.getContext('2d');
canvas.height = viewport.height;
canvas.width = viewport.width;
pdfViewer.appendChild(canvas);
page.render({
canvasContext: context,
viewport: viewport
});
}
}
function setViewMode(mode) {
viewMode = mode;
pdfViewer.className = mode === 'webtoon' ? 'webtoon-view' : 'paged-view';
webtoonViewBtn.classList.toggle('active', mode === 'webtoon');
pagedViewBtn.classList.toggle('active', mode === 'paged');
}
function changeZoom(delta) {
zoomLevel = Math.max(0.2, Math.min(5, zoomLevel + delta));
zoomLevelDisplay.textContent = `${Math.round(zoomLevel * 100)}%`;
if (currentPdf) renderPdf();
}
function handleKeyPress(e) {
if (!currentPdf || e.target.tagName === 'INPUT' || e.target.tagName === 'TEXTAREA') return;
const viewer = pdfViewerContainer;
if (viewMode === 'paged') {
const pageHeight = viewer.querySelector('.pdf-page')?.clientHeight + 40 || viewer.clientHeight;
if (e.key === 'ArrowRight' || e.key === 'ArrowDown') {
e.preventDefault();
viewer.scrollBy({ top: pageHeight, behavior: 'smooth' });
} else if (e.key === 'ArrowLeft' || e.key === 'ArrowUp') {
e.preventDefault();
viewer.scrollBy({ top: -pageHeight, behavior: 'smooth' });
}
} else {
const scrollAmount = e.shiftKey ? viewer.clientHeight * 0.8 : 300;
if (e.key === 'ArrowDown') {
e.preventDefault();
viewer.scrollBy({ top: scrollAmount, behavior: 'smooth' });
} else if (e.key === 'ArrowUp') {
e.preventDefault();
viewer.scrollBy({ top: -scrollAmount, behavior: 'smooth' });
}
}
}
// Initial setup
setViewMode('webtoon');
</script>
</body>
</html>

870
animex/search.html Normal file
View File

@@ -0,0 +1,870 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0, user-scalable=no">
<title>Search & Browse - Media App</title>
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&display=swap" rel="stylesheet">
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.5.1/css/all.min.css">
<link rel="stylesheet" href="Resources/styles.css">
<style>
/* =========================================
1. VARIABLES & RESET (From Reference)
========================================= */
:root {
/* Brand & Palette */
--brand-accent: #ff9500;
--brand-accent-hover: #ffae40;
--background-primary: #0a0a0a;
--background-secondary: #161616;
--background-tertiary: #202020;
/* Text Colors */
--text-primary: #eaeaea;
--text-secondary: #999999;
--text-muted: #666666;
/* UI Elements */
--border-color: #2a2a2a;
--shadow-color: rgba(0, 0, 0, 0.6);
--brand-glow: rgba(255, 149, 0, 0.5);
/* Dimensions & Animation */
--transition-duration: 0.3s;
--border-radius: 8px;
--nav-height: 60px;
}
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
html {
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
}
body {
background-color: var(--background-primary);
color: var(--text-primary);
font-family: 'Inter', -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif;
line-height: 1.6;
overflow-x: hidden;
min-height: 100vh;
}
/* Custom Scrollbar */
::-webkit-scrollbar { width: 8px; height: 8px; }
::-webkit-scrollbar-track { background: var(--background-secondary); }
::-webkit-scrollbar-thumb { background: #444; border-radius: 4px; }
::-webkit-scrollbar-thumb:hover { background: #555; }
/* =========================================
2. APP LAYOUT
========================================= */
.app-container {
width: 100%;
min-height: 100vh;
display: flex;
flex-direction: column;
}
.main-content {
padding: 20px 5%;
max-width: 1200px;
margin: 0 auto;
width: 100%;
flex-grow: 1;
}
.section-title {
font-size: 1.5rem;
font-weight: 700;
color: var(--text-primary);
margin: 30px 0 15px 0;
padding-left: 5px;
border-left: 4px solid var(--brand-accent);
line-height: 1.2;
}
/* =========================================
3. UI COMPONENTS
========================================= */
/* Back Button */
.back-button {
padding: 8px 18px;
margin-bottom: 20px;
background-color: var(--background-secondary);
color: var(--text-secondary);
border: 1px solid var(--border-color);
border-radius: var(--border-radius);
cursor: pointer;
font-size: 0.95em;
font-weight: 600;
display: inline-flex;
align-items: center;
transition: all 0.2s ease;
}
.back-button:hover {
background-color: var(--background-tertiary);
color: var(--text-primary);
border-color: var(--text-muted);
transform: translateX(-3px);
}
.back-button i {
margin-right: 8px;
}
/* Search Bar */
.search-bar-container {
position: relative;
display: flex;
align-items: center;
width: 100%;
max-width: 800px;
margin: 10px auto 25px auto;
padding: 5px 15px;
border-radius: var(--border-radius);
background-color: var(--background-secondary);
border: 1px solid var(--border-color);
box-shadow: 0 4px 15px var(--shadow-color);
transition: border-color 0.2s ease, box-shadow 0.2s ease;
}
.search-bar-container:focus-within {
border-color: var(--brand-accent);
box-shadow: 0 0 0 1px var(--brand-accent), 0 4px 20px rgba(0,0,0,0.5);
}
.search-bar-container input {
flex-grow: 1;
padding: 12px 10px;
border: none;
background-color: transparent;
color: var(--text-primary);
font-size: 1.05em;
outline: none;
font-family: 'Inter', sans-serif;
font-weight: 500;
}
.search-bar-container input::placeholder {
color: var(--text-muted);
font-weight: 400;
}
.search-bar-container .fa-search {
cursor: pointer;
color: var(--text-secondary);
font-size: 1.1em;
margin-right: 10px;
transition: color 0.2s;
}
.search-bar-container input:focus + .fa-search,
.search-bar-container:focus-within .fa-search {
color: var(--brand-accent);
}
.search-bar-container #clear-search-btn {
display: none;
background: none;
border: none;
color: var(--text-muted);
cursor: pointer;
padding: 5px;
font-size: 1.1em;
transition: color 0.2s;
}
.search-bar-container #clear-search-btn:hover {
color: var(--text-primary);
}
/* Recent Search Items */
.recent-search-item {
background-color: var(--background-secondary);
border: 1px solid var(--border-color);
color: var(--text-secondary);
padding: 8px 16px;
border-radius: 20px;
margin: 6px;
display: inline-flex;
align-items: center;
font-size: 0.9em;
font-weight: 500;
cursor: pointer;
transition: all 0.2s ease;
}
.recent-search-item:hover {
background-color: var(--background-tertiary);
color: var(--text-primary);
border-color: var(--text-muted);
}
.recent-search-item span {
margin-right: 10px;
}
.recent-search-item .fa-times {
font-size: 0.9em;
opacity: 0.6;
transition: opacity 0.2s;
}
.recent-search-item:hover .fa-times {
opacity: 1;
color: var(--brand-accent);
}
.item-list#search-item-list.recent-searches-active {
display: flex;
flex-wrap: wrap;
padding-left: 0;
margin-top: 10px;
}
/* List Items (Cards) */
.list-item.card-style {
display: flex;
align-items: center;
background-color: var(--background-secondary);
border: 1px solid var(--border-color);
border-radius: var(--border-radius);
margin-bottom: 12px;
padding: 10px;
cursor: pointer;
transition: transform 0.2s ease, border-color 0.2s ease, box-shadow 0.2s ease, background-color 0.2s;
overflow: hidden;
}
.list-item.card-style:hover {
background-color: #1a1a1a;
transform: translateY(-2px);
border-color: var(--brand-accent);
box-shadow: 0 5px 15px -5px var(--shadow-color);
}
.list-item.card-style .item-thumbnail img {
width: 60px;
height: 85px;
object-fit: cover;
border-radius: 4px;
margin-right: 15px;
background-color: var(--background-tertiary);
}
.list-item.card-style .item-details {
flex-grow: 1;
min-width: 0; /* Prevents flex overflow */
}
.list-item.card-style .item-title {
font-size: 1.05rem;
font-weight: 600;
color: var(--text-primary);
margin-bottom: 6px;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.list-item.card-style .item-meta {
margin-bottom: 6px;
}
.list-item.card-style .meta-pill {
background-color: rgba(255, 149, 0, 0.1);
color: var(--brand-accent);
border: 1px solid rgba(255, 149, 0, 0.2);
padding: 2px 8px;
border-radius: 4px;
font-size: 0.75em;
font-weight: 700;
margin-right: 6px;
display: inline-block;
}
.list-item.card-style .item-description {
font-size: 0.85em;
color: var(--text-secondary);
line-height: 1.4;
overflow: hidden;
text-overflow: ellipsis;
display: -webkit-box;
-webkit-line-clamp: 2;
-webkit-box-orient: vertical;
}
.list-item.card-style .item-arrow {
margin-left: 15px;
color: var(--border-color);
font-size: 0.9em;
transition: transform 0.2s, color 0.2s;
}
.list-item.card-style:hover .item-arrow {
color: var(--brand-accent);
transform: translateX(3px);
}
/* Search Options Tabs */
.search-options-list {
display: flex;
justify-content: center;
gap: 12px;
margin: 0 auto 20px auto;
padding: 0;
list-style: none;
flex-wrap: wrap;
}
.search-option-btn {
background: transparent;
border: 1px solid transparent;
color: var(--text-secondary);
font-size: 0.95em;
font-weight: 600;
padding: 8px 20px;
border-radius: 20px;
cursor: pointer;
transition: all 0.2s ease;
display: flex;
align-items: center;
gap: 8px;
}
.search-option-btn:hover {
color: var(--text-primary);
background-color: rgba(255, 255, 255, 0.05);
}
.search-option-btn.active {
background: rgba(255, 149, 0, 0.15);
color: var(--brand-accent);
border-color: rgba(255, 149, 0, 0.3);
box-shadow: 0 0 15px rgba(255, 149, 0, 0.1);
}
.search-option-btn i {
font-size: 0.9em;
}
/* --- Source Toggle (for Manga) --- */
.source-toggle-container {
position: relative;
background-color: var(--background-secondary);
border-radius: 25px;
padding: 4px;
display: flex;
border: 1px solid var(--border-color);
max-width: 240px;
justify-content: center;
margin: 0 auto 20px auto;
box-shadow: inset 0 2px 5px rgba(0,0,0,0.3);
}
#source-toggle-indicator {
position: absolute;
top: 4px;
left: 4px;
height: calc(100% - 8px);
background-color: var(--brand-accent);
border-radius: 20px;
transition: transform 0.3s cubic-bezier(0.25, 0.8, 0.25, 1), width 0.3s cubic-bezier(0.25, 0.8, 0.25, 1);
z-index: 1;
box-shadow: 0 2px 8px rgba(255, 149, 0, 0.4);
}
.source-toggle-btn {
padding: 6px 20px;
font-size: 0.85em;
font-weight: 700;
background-color: transparent;
color: var(--text-muted);
border: none;
cursor: pointer;
transition: color 0.3s ease;
z-index: 2;
border-radius: 20px;
text-transform: uppercase;
letter-spacing: 0.5px;
}
.source-toggle-btn.active {
color: #000; /* Contrast against orange pill */
}
/* Fix for when toggle isn't active/hovered */
.source-toggle-btn:hover:not(.active) {
color: var(--text-primary);
}
/* --- Browse View --- */
.horizontal-scroll-container {
display: flex;
overflow-x: auto;
overflow-y: hidden;
padding: 10px 0 25px 0;
-webkit-overflow-scrolling: touch;
gap: 15px;
}
.horizontal-scroll-container::-webkit-scrollbar { height: 6px; }
.horizontal-scroll-container::-webkit-scrollbar-thumb { background-color: var(--border-color); }
.genre-chip {
background-color: var(--background-secondary);
color: var(--text-secondary);
padding: 10px 24px;
border-radius: 8px; /* Matching border radius variable generally */
white-space: nowrap;
cursor: pointer;
transition: all 0.2s ease;
border: 1px solid var(--border-color);
font-size: 0.9em;
font-weight: 600;
}
.genre-chip:hover {
background-color: var(--background-tertiary);
color: var(--text-primary);
border-color: var(--brand-accent);
transform: translateY(-2px);
}
/* Popular Cards (Vertical) */
.popular-item-card {
flex: 0 0 150px;
cursor: pointer;
display: flex;
flex-direction: column;
background-color: var(--background-secondary);
border-radius: var(--border-radius);
overflow: hidden;
transition: transform 0.3s ease, box-shadow 0.3s ease;
border: 1px solid transparent;
}
.popular-item-card:hover {
transform: translateY(-5px);
box-shadow: 0 10px 25px var(--shadow-color);
border-color: var(--border-color);
}
.popular-item-card img {
width: 100%;
height: 220px;
object-fit: cover;
background-color: var(--background-tertiary);
transition: transform 0.3s ease;
}
.popular-item-card:hover img {
transform: scale(1.05);
}
.popular-item-card .item-title {
padding: 10px;
font-size: 0.9em;
font-weight: 600;
color: var(--text-primary);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
background: var(--background-secondary);
position: relative;
z-index: 2;
}
#genre-results-list {
padding-top: 10px;
display: grid;
grid-template-columns: repeat(auto-fill, minmax(300px, 1fr));
gap: 15px;
}
/* Override list items inside genre results to grid if preferred,
but based on JS structure it appends .card-style divs.
We'll keep them consistent with search results for now,
but handle the container layout. */
#genre-results-list .list-item.card-style {
margin-bottom: 0;
height: 100%;
}
/* Loading / Error States */
p { color: var(--text-secondary); }
</style>
</head>
<body>
<div class="app-container">
<main class="main-content">
<!-- Universal Header: Search Bar and Tabs -->
<div class="search-bar-container" id="search-bar-container">
<input type="text" id="search-input" placeholder="Type to Search Anime...">
<i class="fas fa-search" id="search-icon"></i>
<button id="clear-search-btn" style="display:none;" aria-label="Clear search" tabindex="0"><i class="fas fa-times"></i></button>
</div>
<div class="source-toggle-container" id="manga-source-toggle" style="display: none;">
<span id="source-toggle-indicator"></span>
<button class="source-toggle-btn active" data-source="jikan">Jikan</button>
<button class="source-toggle-btn" data-source="mangadex">MangaDex</button>
</div>
<ul class="search-options-list" id="search-options-list">
<li><button class="search-option-btn active" data-mode="anime" type="button"><i class="fa-solid fa-tv"></i> Anime</button></li>
<li><button class="search-option-btn" data-mode="manga" type="button"><i class="fa-solid fa-book"></i> Manga</button></li>
<li><button class="search-option-btn" data-mode="browse" type="button"><i class="fa-solid fa-compass"></i> Browse</button></li>
</ul>
<!-- Search Content Area -->
<section id="search-interaction-area">
<h2 class="section-title" id="results-title">Recents</h2>
<div class="item-list" id="search-item-list"></div>
</section>
<!-- Browse Content Area -->
<section id="browse-interaction-area" style="display: none;">
<!-- Genre results view -->
<div id="genre-results-view" style="display: none;">
<button id="back-to-genres-btn" class="back-button"><i class="fas fa-arrow-left"></i> Back to Genres</button>
<h2 class="section-title" id="genre-results-title">Genre Results</h2>
<ul class="search-options-list" id="genre-results-tabs">
<li><button class="search-option-btn active" id="genre-anime-btn" type="button"><i class="fa-solid fa-tv"></i> Anime</button></li>
<li><button class="search-option-btn" id="genre-manga-btn" type="button"><i class="fa-solid fa-book"></i> Manga</button></li>
</ul>
<div class="item-list" id="genre-results-list"></div>
</div>
<!-- Main browse view -->
<div id="main-browse-view">
<h2 class="section-title">Browse by Genre</h2>
<div class="horizontal-scroll-container" id="genre-list-container"></div>
<h2 class="section-title">Popular Anime</h2>
<div class="horizontal-scroll-container" id="popular-anime-list"></div>
<h2 class="section-title">Popular Manga</h2>
<div class="horizontal-scroll-container" id="popular-manga-list"></div>
</div>
</section>
</main>
</div>
<script>
document.addEventListener('DOMContentLoaded', () => {
// --- DOM Element Selectors ---
const mainContent = document.querySelector('.main-content');
const searchInput = document.getElementById('search-input');
const searchIcon = document.getElementById('search-icon');
const searchItemList = document.getElementById('search-item-list');
const resultsTitle = document.getElementById('results-title');
const clearSearchBtn = document.getElementById('clear-search-btn');
// Main Sections & Tabs
const searchInteractionArea = document.getElementById('search-interaction-area');
const browseInteractionArea = document.getElementById('browse-interaction-area');
const searchBarContainer = document.getElementById('search-bar-container');
const mainTabs = document.querySelectorAll('#search-options-list .search-option-btn');
const mangaSourceToggle = document.getElementById('manga-source-toggle');
const sourceToggleIndicator = document.getElementById('source-toggle-indicator');
// Browse View Elements
const mainBrowseView = document.getElementById('main-browse-view');
const genreListContainer = document.getElementById('genre-list-container');
const popularAnimeList = document.getElementById('popular-anime-list');
const popularMangaList = document.getElementById('popular-manga-list');
// Genre Results View Elements
const genreResultsView = document.getElementById('genre-results-view');
const backToGenresBtn = document.getElementById('back-to-genres-btn');
const genreResultsTitle = document.getElementById('genre-results-title');
const genreResultsList = document.getElementById('genre-results-list');
const genreAnimeBtn = document.getElementById('genre-anime-btn');
const genreMangaBtn = document.getElementById('genre-manga-btn');
// --- State Variables ---
let searchMode = 'anime'; // 'anime', 'manga'
let mangaSource = 'jikan'; // 'jikan', 'mangadex'
let currentView = 'search'; // 'search' or 'browse'
let currentGenre = { id: null, name: null };
let allSearchResults = [];
let searchTimeout;
const serverUrl = `http://${localStorage.getItem("extension_server_ip") || "localhost"}:7275`;
// --- View Management ---
function updateToggleIndicator() {
const activeButton = mangaSourceToggle.querySelector('.source-toggle-btn.active');
if (activeButton) {
sourceToggleIndicator.style.width = `${activeButton.offsetWidth}px`;
sourceToggleIndicator.style.transform = `translateX(${activeButton.offsetLeft - 4}px)`; // Adjusted for padding
}
}
function switchView(view) {
currentView = view;
searchInteractionArea.style.display = 'none';
browseInteractionArea.style.display = 'none';
searchBarContainer.style.display = (view === 'browse') ? 'none' : 'flex';
mangaSourceToggle.style.display = (view === 'search' && searchMode === 'manga') ? 'flex' : 'none';
if (view === 'search') {
searchInteractionArea.style.display = 'block';
if (!searchInput.value.trim()) displayRecentSearches();
} else if (view === 'browse') {
browseInteractionArea.style.display = 'block';
if (genreListContainer.innerHTML.trim() === '') loadBrowseContent();
}
if (mangaSourceToggle.style.display === 'flex') {
setTimeout(updateToggleIndicator, 50);
}
}
// --- Iframe Overlay Logic ---
function openSeriesInfo(id, type) {
let url;
if (type === 'mangadex') {
url = `manga-info.html?source=mangadex&id=${id}`;
} else {
url = type === 'manga' ? `manga-info.html?id=${id}` : `series-info.html?id=${id}`;
}
window.parent.openPopup(url);
}
// --- API & Rendering Helper Functions ---
const apiCall = async (url) => {
const response = await fetch(url);
if (!response.ok) throw new Error(`API error: ${response.status}`);
return response.json();
};
function createResultCard(item, type) {
const listItem = document.createElement('div');
listItem.className = 'list-item card-style';
let id, title, imageUrl, score, episodes, chapters, synopsis;
if (type === 'mangadex') {
id = item.id;
title = item.attributes.title.en || Object.values(item.attributes.title)[0];
imageUrl = item.cover_url || '';
score = null; // Not available in this search result
episodes = null;
chapters = null; // Not available
synopsis = item.attributes.description.en || 'No description available.';
listItem.addEventListener('click', () => openSeriesInfo(id, 'mangadex'));
} else {
id = item.mal_id;
title = item.title_english || item.title;
imageUrl = item.images?.jpg?.image_url || '';
score = item.score;
episodes = item.episodes;
chapters = item.chapters;
synopsis = item.synopsis || 'No description available.';
listItem.addEventListener('click', () => openSeriesInfo(id, type));
}
const thumbnail = `<div class="item-thumbnail"><img src="${imageUrl}" alt="${title}" loading="lazy"></div>`;
const metaPills = [];
if (score) metaPills.push(`<span class="meta-pill"><i class="fas fa-star" style="margin-right:3px; font-size:0.9em;"></i>${score}</span>`);
if (type === 'anime' && episodes) metaPills.push(`<span class="meta-pill">${episodes} EP</span>`);
if (type === 'manga' && chapters) metaPills.push(`<span class="meta-pill">${chapters} CH</span>`);
const shortSynopsis = synopsis.length > 100 ? synopsis.substring(0, 100) + '...' : synopsis;
const details = `<div class="item-details">
<h3 class="item-title">${title}</h3>
<div class="item-meta">${metaPills.join('')}</div>
<p class="item-description">${shortSynopsis}</p>
</div>`;
const arrow = `<div class="item-arrow"><i class="fas fa-chevron-right"></i></div>`;
listItem.innerHTML = thumbnail + details + arrow;
return listItem;
}
// --- Search Logic ---
const getRecentSearches = () => JSON.parse(localStorage.getItem(`${searchMode}RecentSearches`) || '[]');
const addRecentSearch = (term) => {
if (!term) return;
let searches = getRecentSearches().filter(s => s.toLowerCase() !== term.toLowerCase());
searches.unshift(term);
localStorage.setItem(`${searchMode}RecentSearches`, JSON.stringify(searches.slice(0, 5)));
};
const removeRecentSearch = (term) => {
let searches = getRecentSearches().filter(s => s.toLowerCase() !== term.toLowerCase());
localStorage.setItem(`${searchMode}RecentSearches`, JSON.stringify(searches));
displayRecentSearches();
};
function displayRecentSearches() {
searchItemList.innerHTML = '';
searchItemList.className = 'item-list recent-searches-active';
const searches = getRecentSearches();
resultsTitle.textContent = 'Recents';
if (searches.length === 0) {
searchItemList.innerHTML = '<p style="width: 100%; text-align: center; margin-top: 20px;">No recent searches.</p>';
return;
}
searches.forEach(term => {
const recentItem = document.createElement('div');
recentItem.className = 'recent-search-item';
recentItem.innerHTML = `<span>${term}</span><i class="fas fa-times"></i>`;
recentItem.querySelector('span').addEventListener('click', () => {
searchInput.value = term;
performSearch(term);
});
recentItem.querySelector('i').addEventListener('click', (e) => {
e.stopPropagation();
removeRecentSearch(term);
});
searchItemList.appendChild(recentItem);
});
}
async function performSearch(query) {
if (!query) {
displayRecentSearches();
return;
}
resultsTitle.textContent = `Results for "${query}"`;
searchItemList.innerHTML = '<p style="width: 100%; text-align: center; margin-top: 20px;">Loading...</p>';
searchItemList.className = 'item-list';
try {
let url;
let type;
if (searchMode === 'manga') {
type = mangaSource; // 'jikan' or 'mangadex'
if (mangaSource === 'mangadex') {
url = `${serverUrl}/mangadex/search?q=${encodeURIComponent(query)}`;
} else {
url = `https://api.jikan.moe/v4/manga?q=${encodeURIComponent(query)}&limit=25`;
}
} else {
type = 'anime';
url = `https://api.jikan.moe/v4/anime?q=${encodeURIComponent(query)}&limit=25`;
}
const data = await apiCall(url);
const results = type === 'mangadex' ? data.data : data.data;
displaySearchResults(results, query, type === 'jikan' ? 'manga' : type);
if (results?.length > 0) addRecentSearch(query);
} catch (error) {
console.error("Failed to fetch search results:", error);
searchItemList.innerHTML = `<p style="width: 100%; text-align: center; color: var(--brand-accent); margin-top: 20px;">Error: ${error.message}.</p>`;
}
}
function displaySearchResults(data, query, type) {
allSearchResults = data || [];
searchItemList.innerHTML = '';
if (allSearchResults.length === 0) {
searchItemList.innerHTML = `<p style="width: 100%; text-align: center; margin-top: 20px;">No results found for "${query}".</p>`;
return;
}
allSearchResults.forEach(item => searchItemList.appendChild(createResultCard(item, type)));
}
// --- Browse Logic ---
function createPopularCard(item, type) {
const card = document.createElement('div');
card.className = 'popular-item-card';
card.addEventListener('click', () => openSeriesInfo(item.mal_id, type));
card.innerHTML = `
<img src="${item.images?.jpg?.large_image_url || ''}" alt="${item.title}" loading="lazy">
<div class="item-title">${item.title}</div>`;
return card;
}
async function loadBrowseContent() {
genreListContainer.innerHTML = '<p>Loading genres...</p>';
popularAnimeList.innerHTML = '<p>Loading...</p>';
popularMangaList.innerHTML = '<p>Loading...</p>';
try {
const [genresRes, popularAnimeRes, popularMangaRes] = await Promise.all([
apiCall('https://api.jikan.moe/v4/genres/anime'),
apiCall('https://api.jikan.moe/v4/top/anime?limit=15'),
apiCall('https://api.jikan.moe/v4/top/manga?limit=15')
]);
genreListContainer.innerHTML = '';
genresRes.data.sort((a, b) => a.name.localeCompare(b.name)).forEach(genre => {
const chip = document.createElement('button');
chip.className = 'genre-chip';
chip.textContent = genre.name;
chip.addEventListener('click', () => showGenreResultsView(genre.mal_id, genre.name));
genreListContainer.appendChild(chip);
});
popularAnimeList.innerHTML = '';
popularAnimeRes.data.forEach(item => popularAnimeList.appendChild(createPopularCard(item, 'anime')));
popularMangaList.innerHTML = '';
popularMangaRes.data.forEach(item => popularMangaList.appendChild(createPopularCard(item, 'manga')));
} catch (error) {
console.error("Failed to load browse content:", error);
genreListContainer.innerHTML = `<p style="color:var(--brand-accent);">Error loading genres.</p>`;
popularAnimeList.innerHTML = `<p style="color:var(--brand-accent);">Error loading popular anime.</p>`;
}
}
function showGenreResultsView(genreId, genreName) {
currentGenre = { id: genreId, name: genreName };
mainBrowseView.style.display = 'none';
genreResultsView.style.display = 'block';
genreResultsTitle.textContent = genreName;
genreAnimeBtn.classList.add('active');
genreMangaBtn.classList.remove('active');
fetchAndDisplayGenreResults('anime');
}
function showMainBrowseView() {
genreResultsView.style.display = 'none';
mainBrowseView.style.display = 'block';
}
async function fetchAndDisplayGenreResults(type) {
genreResultsList.innerHTML = '<p style="width: 100%; text-align: center;">Loading...</p>';
try {
const data = await apiCall(`https://api.jikan.moe/v4/${type}?genres=${currentGenre.id}&limit=24`);
genreResultsList.innerHTML = '';
if (!data.data || data.data.length === 0) {
genreResultsList.innerHTML = `<p style="width: 100%; text-align: center;">No ${type} found.</p>`;
return;
}
data.data.forEach(item => genreResultsList.appendChild(createResultCard(item, type)));
} catch (error) {
console.error(`Failed to fetch ${type}:`, error);
genreResultsList.innerHTML = `<p style="width: 100%; text-align: center; color: var(--brand-accent);">Error.</p>`;
}
}
// --- Event Listeners ---
document.getElementById('search-options-list').addEventListener('click', (e) => {
const button = e.target.closest('.search-option-btn');
if (!button) return;
const newMode = button.dataset.mode;
mainTabs.forEach(btn => btn.classList.remove('active'));
button.classList.add('active');
if (newMode === 'browse') {
switchView('browse');
} else {
searchMode = newMode;
searchInput.placeholder = `Type to Search ${searchMode.charAt(0).toUpperCase() + searchMode.slice(1)}...`;
switchView('search');
if (searchInput.value.trim()) {
performSearch(searchInput.value.trim());
} else {
displayRecentSearches();
}
}
});
mangaSourceToggle.addEventListener('click', (e) => {
const button = e.target.closest('.source-toggle-btn');
if (button && button.dataset.source !== mangaSource) {
mangaSource = button.dataset.source;
mangaSourceToggle.querySelector('.active').classList.remove('active');
button.classList.add('active');
updateToggleIndicator();
if (searchInput.value.trim()) {
performSearch(searchInput.value.trim());
}
}
});
searchInput.addEventListener('input', () => {
clearTimeout(searchTimeout);
searchTimeout = setTimeout(() => performSearch(searchInput.value.trim()), 500);
clearSearchBtn.style.display = searchInput.value ? 'inline-flex' : 'none';
});
searchInput.addEventListener('keypress', e => e.key === 'Enter' && (clearTimeout(searchTimeout), performSearch(searchInput.value.trim())));
searchIcon.addEventListener('click', () => (clearTimeout(searchTimeout), performSearch(searchInput.value.trim())));
clearSearchBtn.addEventListener('click', () => {
searchInput.value = '';
clearSearchBtn.style.display = 'none';
searchInput.focus();
displayRecentSearches();
});
backToGenresBtn.addEventListener('click', showMainBrowseView);
genreAnimeBtn.addEventListener('click', () => {
genreAnimeBtn.classList.add('active');
genreMangaBtn.classList.remove('active');
fetchAndDisplayGenreResults('anime');
});
genreMangaBtn.addEventListener('click', () => {
genreMangaBtn.classList.add('active');
genreAnimeBtn.classList.remove('active');
fetchAndDisplayGenreResults('manga');
});
// --- Initial Load ---
switchView('search');
});
</script>
</body>
</html>

1749
animex/series-info.html Normal file

File diff suppressed because it is too large Load Diff

1410
animex/settings.html Normal file

File diff suppressed because it is too large Load Diff

BIN
animex/src/.DS_Store vendored Normal file

Binary file not shown.

503
animex/src/csPlayer.js Normal file
View File

@@ -0,0 +1,503 @@
// csPlayer.js - Patched to hide Related Videos on Pause
function $(selector,parent){
var x;
try{
const elements = document.querySelectorAll(selector);
if(elements.length == 1){x = elements[0]}
else if(elements.length == 0){x = null}
else{x = elements}
}catch(error){
x = error;
}return x;
}
var csPlayer ={
csPlayers : {},
preSetup: (videoTag,playerTagId,defaultId)=>{
var theme =("theme" in csPlayer.csPlayers[videoTag]["params"]) ? csPlayer.csPlayers[videoTag]["params"]["theme"] : null;
var themeClass = theme ? "theme-"+theme : "";
return new Promise((resolve, reject) => {
$("#"+videoTag).innerHTML =`
<div class="csPlayer ${themeClass}">
<div class="csPlayer-container">
<span>
<div></div>
<i class="ti ti-player-play-filled csPlayer-loading"></i>
<div></div>
</span>
<div id=${playerTagId}></div>
</div>
<div class="csPlayer-controls-box">
<main>
<i class="ti ti-rewind-backward-10"></i>
<i class="ti csPlayer-play-pause-btn ti-player-play-filled"></i>
<i class="ti ti-rewind-forward-10"></i>
</main>
<div class="csPlayer-controls">
<p>00:00</p>
<div><span></span>
<input type="range" min="0" max="100" value="0" step="1"></div>
<p>00:00</p>
<i class="ti ti-settings settingsBtn"></i>
<i class="ti ti-maximize fsBtn"></i>
</div>
<div class="csPlayer-settings-box">
<p>Speed<b>1x</b><i class="ti ti-caret-right-filled"></i></p>
<span>
<label><input type="radio" name=${videoTag}1>0.75x</label>
<label><input type="radio" name=${videoTag}1 checked>1x</label>
<label><input type="radio" name=${videoTag}1>1.25x</label>
<label><input type="radio" name=${videoTag}1>1.5x</label>
<label><input type="radio" name=${videoTag}1>1.75x</label>
<label><input type="radio" name=${videoTag}1>2x</label>
</span>
<p>Quality<b>auto</b><i class="ti ti-caret-right-filled"></i></p>
<span>
<label><input type="radio" name=${videoTag}2 checked>auto</label>
</span>
</div>
</div>
</div>`;
resolve();
});
},
pauseVideoWithPromise:(x)=>{
return new Promise((resolve, reject) => {
try{
x.pauseVideo()
resolve('Video paused');
}catch(error){
reject('Error pausing video: ' + error);
}
});
},
YtSetup:(videoTag,playerTagId,defaultId)=>{
var parent = document.querySelector("#"+playerTagId).closest(".csPlayer");
var controlsTimeout = null;
return new Promise((resolve, reject) => {
csPlayer.csPlayers[videoTag]["videoTag"] = new YT.Player(playerTagId,{
videoId: csPlayer.csPlayers[videoTag]["params"]["defaultId"],
playerVars:{
controls: 0,
mute: 1,
autoplay: 1,
disablekb: 1,
color: "white",
fs: 0,
playsinline: 1,
rel: 0,
loop: 0,
cc_load_policy: 3,
showinfo: 0,
iv_load_policy: 3,
},
events:{
'onReady':()=>{
if($("#"+videoTag) != null && videoTag){
csPlayer.pauseVideoWithPromise(csPlayer.csPlayers[videoTag]["videoTag"]).then(()=>{
parent.querySelector(".csPlayer-container iframe").addEventListener("load",()=>{
parent.querySelector(".csPlayer-container span i").classList.remove("csPlayer-loading");
csPlayer.csPlayers[videoTag]["videoTag"].addEventListener('onStateChange', onPlayerStateChange);
parent.querySelector(".csPlayer-controls-box main i:nth-of-type(1)").addEventListener("click", backward);
parent.querySelector(".csPlayer-controls-box main i:nth-of-type(2)").addEventListener("click", togglePlayPause);
parent.querySelector(".csPlayer-controls-box main i:nth-of-type(3)").addEventListener("click", forward);
csPlayer.csPlayers[videoTag]["TextTimeInterval"] = setInterval(updateTextTime,1000);
csPlayer.csPlayers[videoTag]["TimeSliderInterval"] = setInterval(updateTimeSlider,1000); parent.querySelector(".csPlayer-controls-box .csPlayer-controls input").addEventListener("input",updateSlider);
parent.querySelector(".csPlayer-controls-box .csPlayer-controls .fsBtn").addEventListener("click",toggleFullscreen);
document.fullscreenEnabled ? parent.querySelector(".csPlayer-controls-box .csPlayer-controls .fsBtn").style.display ="block" : parent.querySelector(".csPlayer-controls-box .csPlayer-controls .fsBtn").style.display ="none";
parent.querySelector(".csPlayer-controls-box .csPlayer-controls .settingsBtn").addEventListener("click",toggleSettings);
});
});
}},
}
});
resolve();
});
function backward(){
updateTextTime()
updateTimeSlider()
var currentTime = csPlayer.csPlayers[videoTag]["videoTag"].getCurrentTime();
csPlayer.csPlayers[videoTag]["videoTag"].seekTo(Math.max(0, currentTime - 10), true);
clearTimeout(controlsTimeout);
controlsTimeout = setTimeout(()=>{parent.querySelector(".csPlayer-controls-box").classList.remove("csPlayer-controls-open");},3000);
}
function forward(){
updateTextTime()
updateTimeSlider()
var currentTime = csPlayer.csPlayers[videoTag]["videoTag"].getCurrentTime();
csPlayer.csPlayers[videoTag]["videoTag"].seekTo(currentTime + 10, true);
clearTimeout(controlsTimeout);
controlsTimeout = setTimeout(()=>{parent.querySelector(".csPlayer-controls-box").classList.remove("csPlayer-controls-open");},3000);
}
function togglePlayPause(){
if(csPlayer.csPlayers[videoTag]["isPlaying"]){
csPlayer.csPlayers[videoTag]["videoTag"].pauseVideo();
clearTimeout(controlsTimeout);
}else{
csPlayer.csPlayers[videoTag]["videoTag"].playVideo();
clearTimeout(controlsTimeout);
controlsTimeout = setTimeout(()=>{parent.querySelector(".csPlayer-controls-box").classList.remove("csPlayer-controls-open");},3000);
}
}
function formatTime(seconds) {
const h = Math.floor(seconds / 3600),
m = Math.floor((seconds % 3600) / 60),
s = Math.floor(seconds % 60);
return h > 0 ? `${String(h).padStart(2, '0')}:${String(m).padStart(2, '0')}:${String(s).padStart(2, '0')}` : `${String(m).padStart(2, '0')}:${String(s).padStart(2, '0')}`;
}
function timeToSeconds(t){
var p = t.split(":").map(Number);
return p.length === 3 ? p[0] * 3600 + p[1] * 60 + p[2] : p[0] * 60 + p[1];
}
function updateTextTime(){
var currentTime = csPlayer.csPlayers[videoTag]["videoTag"].getCurrentTime();
var duration = csPlayer.csPlayers[videoTag]["videoTag"].getDuration();
parent.querySelector(".csPlayer-controls-box .csPlayer-controls p:nth-of-type(1)").innerHTML = formatTime(String(currentTime));
parent.querySelector(".csPlayer-controls-box .csPlayer-controls p:nth-of-type(2)").innerHTML = formatTime(String(duration));
}
function updateTimeSlider(){
var slider = parent.querySelector(".csPlayer-controls-box .csPlayer-controls div input");
var currentTime = csPlayer.csPlayers[videoTag]["videoTag"].getCurrentTime();
var duration = csPlayer.csPlayers[videoTag]["videoTag"].getDuration();
var progress = (currentTime/duration)*100;
var loaded = (csPlayer.csPlayers[videoTag]["videoTag"].getVideoLoadedFraction())*100;
slider.value = progress;
slider.style.background =`linear-gradient(to right, var(--sliderSeekTrackColor) ${progress}%, transparent ${progress}%)`;
parent.querySelector(".csPlayer-controls-box .csPlayer-controls div span").style.width = loaded+"%";
}
function updateSlider(){
clearTimeout(controlsTimeout);
var slider = parent.querySelector(".csPlayer-controls-box .csPlayer-controls div input");
var duration = csPlayer.csPlayers[videoTag]["videoTag"].getDuration();
var progress = slider.value;
slider.style.background =`linear-gradient(to right, var(--sliderSeekTrackColor) ${progress}%, transparent ${progress}%)`;
csPlayer.csPlayers[videoTag]["videoTag"].seekTo((slider.value/100)*duration);
slider.value = slider.value;
controlsTimeout = setTimeout(()=>{parent.querySelector(".csPlayer-controls-box").classList.remove("csPlayer-controls-open");},3000);
}
function toggleFullscreen(){
const videoContainer = parent;
if(!document.fullscreenElement && document.fullscreenEnabled){
if(videoContainer.requestFullscreen){
videoContainer.requestFullscreen();
}else if(videoContainer.mozRequestFullScreen){
videoContainer.mozRequestFullScreen();
}else if(videoContainer.webkitRequestFullscreen){
videoContainer.webkitRequestFullscreen();
}else if(videoContainer.msRequestFullscreen){
videoContainer.msRequestFullscreen();
}
}
else if(document.fullscreenElement && document.fullscreenEnabled){
if(document.exitFullscreen){
document.exitFullscreen();
}else if(document.mozCancelFullScreen){
document.mozCancelFullScreen();
}else if(document.webkitExitFullscreen){
document.webkitExitFullscreen();
}else if(document.msExitFullscreen){
document.msExitFullscreen();
}
}else{
console.warn("Fullscreen api not supported in your browser.");
}
}
function resetSettings(){
var settings = parent.querySelector(".csPlayer-controls-box .csPlayer-settings-box");
settings.querySelectorAll("p").forEach(pin=>{
pin.nextElementSibling.style.maxHeight ="0px";
});
}
function toggleSettings(){
const targetElement = parent.querySelector(".csPlayer-controls-box");
var settings = parent.querySelector(".csPlayer-controls-box .csPlayer-settings-box");
var qualities = csPlayer.csPlayers[videoTag]["videoTag"].getAvailableQualityLevels();
for(x of qualities){
if(!settings.querySelector("span:nth-of-type(2)").innerHTML.includes(x)){
settings.querySelector("span:nth-of-type(2)").innerHTML +=`<label><input type="radio" name=${videoTag}2>${x}</label>`;
}
}
const obsrvr = new MutationObserver((mutationsList)=>{
mutationsList.forEach((mutation) => {
if(mutation.attributeName === 'class'){
if(!targetElement.className.includes("open")){
settings.style.display ="none";
resetSettings();
}
}
});
});
obsrvr.observe(targetElement,{
attributes: true,
attributeFilter: ['class'],
});
if(settings.style.display =="block"){
settings.style.display ="none";
}else{
settings.style.display ="block";
}
settings.addEventListener("click",()=>{
clearTimeout(controlsTimeout);
controlsTimeout = setTimeout(()=>{parent.querySelector(".csPlayer-controls-box").classList.remove("csPlayer-controls-open");},3000);
});
settings.querySelectorAll("p").forEach(pin=>{
pin.addEventListener("click",()=>{
settings.querySelectorAll("p").forEach(Allpin=>{
Allpin.nextElementSibling.style.maxHeight ="0px";
});
pin.nextElementSibling.style.maxHeight ="400px";
});
});
settings.querySelectorAll("span:nth-of-type(1) input").forEach(spdInput=>{
spdInput.addEventListener("change",(e)=>{
var value = e.target.parentElement.innerText.slice(0,-1);
settings.querySelector("p:nth-of-type(1) b").innerText = value+"x";
csPlayer.csPlayers[videoTag]["videoTag"].setPlaybackRate(Number(value));
});
});
settings.querySelectorAll("span:nth-of-type(2) input").forEach(qualInput=>{
qualInput.addEventListener("change",(e)=>{
var value = e.target.parentElement.innerText;
try{
var currentTime = csPlayer.csPlayers[videoTag]["videoTag"].getCurrentTime();
settings.querySelector("p:nth-of-type(2) b").innerText = value;
csPlayer.csPlayers[videoTag]["videoTag"].setPlaybackQuality(value);
}catch(error){
throw new Error(error);
settings.querySelector("p:nth-of-type(2) b").innerText = value;
}
});
});
}
// ==========================================
// KEY CHANGE HERE: Hide Overlay logic
// ==========================================
function onPlayerStateChange(event){
if(event.data == YT.PlayerState.PLAYING){
csPlayer.csPlayers[videoTag]["isPlaying"] = true;
csPlayer.csPlayers[videoTag]["playerState"] ="playing";
parent.querySelector(".csPlayer-controls-box main .csPlayer-play-pause-btn").className ="ti csPlayer-play-pause-btn ti-player-pause-filled";
// Hide the cover overlay when playing
parent.querySelector(".csPlayer-container span").style.display ="none";
csPlayer.csPlayers[videoTag]["videoTag"].unMute();
parent.querySelector(".csPlayer-container").style.pointerEvents ="none";
parent.querySelector(".csPlayer-controls-box").style.display ="flex";
clearTimeout(controlsTimeout);
controlsTimeout = setTimeout(()=>{parent.querySelector(".csPlayer-controls-box").classList.remove("csPlayer-controls-open");},3000);
parent.querySelector(".csPlayer-controls-box").onclick = function(e){
if(!parent.querySelector(".csPlayer-controls-box main").contains(e.target) && !parent.querySelector(".csPlayer-controls-box .csPlayer-controls").contains(e.target) && !parent.querySelector(".csPlayer-controls-box .csPlayer-settings-box").contains(e.target)){
if(parent.querySelector(".csPlayer-controls-box").classList.contains("csPlayer-controls-open")){
parent.querySelector(".csPlayer-controls-box").classList.remove("csPlayer-controls-open");
clearTimeout(controlsTimeout);
}else{
parent.querySelector(".csPlayer-controls-box").classList.add("csPlayer-controls-open");
clearTimeout(controlsTimeout);
controlsTimeout = setTimeout(()=>{parent.querySelector(".csPlayer-controls-box").classList.remove("csPlayer-controls-open");},3000);
}
}
}
parent.querySelector(".csPlayer-controls-box .csPlayer-controls").addEventListener("click", ()=>{
clearTimeout(controlsTimeout);
controlsTimeout = setTimeout(()=>{parent.querySelector(".csPlayer-controls-box").classList.remove("csPlayer-controls-open");},3000);
});
} else if(event.data == YT.PlayerState.PAUSED){
clearTimeout(controlsTimeout);
csPlayer.csPlayers[videoTag]["isPlaying"] = false;
csPlayer.csPlayers[videoTag]["playerState"] ="paused";
parent.querySelector(".csPlayer-controls-box main .csPlayer-play-pause-btn").className ="ti csPlayer-play-pause-btn ti-player-play-filled";
// === NEW LINE: SHOW COVER ON PAUSE ===
// This covers the YouTube "More Videos" grid with your thumbnail/color
parent.querySelector(".csPlayer-container span").style.display = "flex";
if(!parent.querySelector(".csPlayer-controls-box").classList.contains("csPlayer-controls-open")){
parent.querySelector(".csPlayer-controls-box").classList.add("csPlayer-controls-open");
}
} else if(event.data == YT.PlayerState.BUFFERING){
csPlayer.csPlayers[videoTag]["playerState"] ="buffering";
} else if(event.data == YT.PlayerState.CUED){
csPlayer.csPlayers[videoTag]["playerState"] ="cued";
} else if(event.data == YT.PlayerState.ENDED){
if(csPlayer.csPlayers[videoTag]["params"]["loop"] == true || csPlayer.csPlayers[videoTag]["params"]["loop"] =="true"){
csPlayer.csPlayers[videoTag]["videoTag"].seekTo(0);
}else{
csPlayer.csPlayers[videoTag]["videoTag"].seekTo(0);
csPlayer.csPlayers[videoTag]["videoTag"].pauseVideo();
csPlayer.csPlayers[videoTag]["playerState"] ="ended";
}
}
try{
csPlayer.csPlayers[videoTag]["videoTag"].unloadModule("captions");
csPlayer.csPlayers[videoTag]["videoTag"].unloadModule("cc");
}catch(exception){}
}
},
init:(videoTag,params)=>{
return new Promise((resolve, reject) => {
if(videoTag && params && ("defaultId" in params)){
if($("#"+videoTag)!=null){
if(!(videoTag in csPlayer.csPlayers)){
csPlayer.csPlayers[videoTag] = {}
csPlayer.csPlayers[videoTag]["videoTag"] = videoTag;
csPlayer.csPlayers[videoTag]["params"] = params;
if("defaultId" in params){
csPlayer.csPlayers[videoTag]["params"]["defaultId"] = params["defaultId"];
}if("loop" in params){
csPlayer.csPlayers[videoTag]["params"]["loop"] = params["loop"];
}if("thumbnail" in params){
csPlayer.csPlayers[videoTag]["params"]["thumbnail"] = params["thumbnail"];
}if("theme" in params){
csPlayer.csPlayers[videoTag]["params"]["theme"] = params["theme"];
}
csPlayer.csPlayers[videoTag]["isPlaying"] = false;
csPlayer.csPlayers[videoTag]["playerState"] ="paused";
csPlayer.csPlayers[videoTag]["initialized"] = false; csPlayer.preSetup(videoTag,playerTagId="csPlayer-"+videoTag,params["defaultId"]).then(()=>{
var parent = document.querySelector("#"+playerTagId).closest(".csPlayer");
if(("thumbnail" in csPlayer.csPlayers[videoTag]["params"])){
if(csPlayer.csPlayers[videoTag]["params"]["thumbnail"] == true || csPlayer.csPlayers[videoTag]["params"]["thumbnail"] =="true"){
parent.querySelector(".csPlayer-container span").style.backgroundImage =`url("https://img.youtube.com/vi/${csPlayer.csPlayers[videoTag]["params"]["defaultId"]}/maxresdefault.jpg")`;
}else if(csPlayer.csPlayers[videoTag]["params"]["thumbnail"] == false || csPlayer.csPlayers[videoTag]["params"]["thumbnail"] =="false"){
parent.querySelector(".csPlayer-container span").style.backgroundImage ="none";
}else{
parent.querySelector(".csPlayer-container span").style.backgroundImage =`url(${csPlayer.csPlayers[videoTag]["params"]["thumbnail"]})`;
}} csPlayer.YtSetup(videoTag,playerTagId="csPlayer-"+videoTag,params["defaultId"]).then(()=>{
csPlayer.csPlayers[videoTag]["initialized"] = true;
console.log("Player",videoTag,"initialized.");
});
});
}else{
throw new Error("Player "+videoTag+" already exists.");
}}else{
throw new Error("No tag with id "+videoTag+" available in the document.");
}}else{
throw new Error("Init function must have two parameters and second parameter must have defaultId.");
}
resolve();});
},
pause:(videoTag)=>{
if(videoTag){
if((videoTag in csPlayer.csPlayers) && csPlayer.csPlayers[videoTag]["initialized"] == true){
csPlayer.csPlayers[videoTag]["videoTag"].pauseVideo();
}else{
throw new Error("Player "+videoTag+" is not initialized yet.")
}}else{
throw new Error("pause function must have player id as a parameter.")
}
},
play:(videoTag)=>{
if(videoTag){
if((videoTag in csPlayer.csPlayers) && csPlayer.csPlayers[videoTag]["initialized"] == true){
if(!csPlayer.csPlayers[videoTag]["videoTag"].isMuted()){
csPlayer.csPlayers[videoTag]["videoTag"].playVideo();
}else{
throw new Error("Before calling play function, the video must be played atleat once.");
}}else{
throw new Error("Player "+videoTag+" is not initialized yet.")
}}else{
throw new Error("play function must have player id as a parameter.")
}
},
getDuration:(videoTag)=>{
if(videoTag){
if((videoTag in csPlayer.csPlayers) && csPlayer.csPlayers[videoTag]["initialized"] == true){
return csPlayer.csPlayers[videoTag]["videoTag"].getDuration();
}else{
throw new Error("Player "+videoTag+" is not initialized yet.")
}}else{
throw new Error("getDuration function must have player id as a parameter.")
}
},
getCurrentTime:(videoTag)=>{
if(videoTag){
if((videoTag in csPlayer.csPlayers) && csPlayer.csPlayers[videoTag]["initialized"] == true){
return csPlayer.csPlayers[videoTag]["videoTag"].getCurrentTime();
}else{
throw new Error("Player "+videoTag+" is not initialized yet.")
}}else{
throw new Error("getCurrentTime function must have player id as a parameter.")
}
},
getVideoTitle:(videoTag)=>{
if(videoTag){
if((videoTag in csPlayer.csPlayers) && csPlayer.csPlayers[videoTag]["initialized"] == true){
return csPlayer.csPlayers[videoTag]["videoTag"].getVideoData().title;
}else{
throw new Error("Player "+videoTag+" is not initialized yet.")
}}else{
throw new Error("getVideoTitle function must have player id as a parameter.")
}
},
getPlayerState:(videoTag)=>{
if(videoTag){
if((videoTag in csPlayer.csPlayers) && csPlayer.csPlayers[videoTag]["initialized"] == true){
return csPlayer.csPlayers[videoTag]["playerState"];
}else{
throw new Error("Player "+videoTag+" is not initialized yet.")
}}else{
throw new Error("getPlayerState function must have player id as a parameter.")
}
},
changeVideo:(videoTag,videoId)=>{
if(videoTag && videoId){
if((videoTag in csPlayer.csPlayers) && csPlayer.csPlayers[videoTag]["initialized"] == true){
if(!csPlayer.csPlayers[videoTag]["videoTag"].isMuted()){
csPlayer.csPlayers[videoTag]["videoTag"].loadVideoById(videoId,0);
}else{
throw new Error("Before calling the changeVideo function, the previous video must be played in the player.");
}
}else{
throw new Error("Player "+videoTag+" is not initialized yet.")
}}else{
throw new Error("changeVideo function must have two parameters, first parameter as player Id and second as the new YouTube video ID.")
}
},
destroy:(videoTag)=>{
if(videoTag){
if((videoTag in csPlayer.csPlayers) && csPlayer.csPlayers[videoTag]["initialized"] == true){
if("TimeSliderInterval" in csPlayer.csPlayers[videoTag]){
clearInterval(csPlayer.csPlayers[videoTag]["TimeSliderInterval"]);
}
if("TextTimeInterval" in csPlayer.csPlayers[videoTag]){
clearInterval(csPlayer.csPlayers[videoTag]["TextTimeInterval"]);
}
csPlayer.csPlayers[videoTag]["videoTag"].destroy();
delete csPlayer.csPlayers[videoTag];
$("#"+videoTag+" .csPlayer").remove();
}else{
throw new Error("Player "+videoTag+" is not initialized yet.")
}}else{
throw new Error("changeVideo function must have two parameters, first parameter as player Id and second as the new YouTube video ID.")
}
},
initialized:(videoTag)=>{
if(videoTag){
if((videoTag in csPlayer.csPlayers)){
return csPlayer.csPlayers[videoTag]["initialized"];
}else{
throw new Error("Player "+videoTag+" doesn't exist. ")
}}else{
throw new Error("pause function must have player id as a parameter.")
}
},
}

434
animex/src/csplayer.css Normal file
View File

@@ -0,0 +1,434 @@
@import url("./icons/tabler-icons.min.css");
* {
margin: 0;
padding: 0;
box-sizing: border-box;
/* outline: 1px solid greenyellow !important;*/
}
.csPlayer {
--playerBg: #000;
--playerColor: #e1e1e1;
--playerBR: 5px;
--startLoaderColor: #e1e1e1;
--startBtnSize: 65px;
--startBtnBg: var(--playerColor);
--startBtnIconColor: var(--playerBg);
--playPauseIconColor: var(--playerColor);
--forwardIconColor: var(--playerColor);
--backwardIconColor: var(--playerColor);
--sliderBg: #383838;
--sliderThumbSize: 15px;
--sliderThumbColor: var(--playerColor);
--sliderSeekTrackColor: var(--playerColor);
--sliderLoadedTrackColor: #878787;
--currentTimeTextColor: var(--playerColor);
--durationTextColor: var(--playerColor);
--settingsBtnColor: var(--playerColor);
--fullscreenBtnColor: var(--playerColor);
--settingsBg: #181818;
--settingsTextColor: var(--playerColor);
--settingsInputIconBg: #4b4b4b;
--settingsInputIconColor: var(--playerColor);
background: var(--playerBg);
display: flex;
flex-direction: column;
justify-content: center;
width: 100%;
aspect-ratio: 16/9;
margin: 0 auto;
user-select: none !important;
position: relative;
border-radius: var(--playerBR);
min-width: 270px !important;
}
.theme-default {
--playerBg: #000;
--playerColor: #e1e1e1;
--playerBR: 5px;
--startLoaderColor: #e1e1e1;
--startBtnSize: 65px;
--startBtnBg: var(--playerColor);
--startBtnIconColor: var(--playerBg);
--playPauseIconColor: var(--playerColor);
--forwardIconColor: var(--playerColor);
--backwardIconColor: var(--playerColor);
--sliderBg: #383838;
--sliderThumbSize: 15px;
--sliderThumbColor: var(--playerColor);
--sliderSeekTrackColor: var(--playerColor);
--sliderLoadedTrackColor: #878787;
--currentTimeTextColor: var(--playerColor);
--durationTextColor: var(--playerColor);
--settingsBtnColor: var(--playerColor);
--fullscreenBtnColor: var(--playerColor);
--settingsBg: #181818;
--settingsTextColor: var(--playerColor);
--settingsInputIconBg: #4b4b4b;
--settingsInputIconColor: var(--playerColor);
}
.theme-youtube {
--playerBg: #000;
--playerColor: #e1e1e1;
--playerBR: 5px;
--startLoaderColor: var(--playerColor);
--startBtnSize: 65px;
--startBtnBg: #fe0001;
--startBtnIconColor: var(--playerColor);
--playPauseIconColor: var(--playerColor);
--forwardIconColor: var(--playerColor);
--backwardIconColor: var(--playerColor);
--sliderBg: #383838;
--sliderThumbSize: 15px;
--sliderThumbColor: #fe0001;
--sliderSeekTrackColor: #fe0001;
--sliderLoadedTrackColor: #787672;
--currentTimeTextColor: var(--playerColor);
--durationTextColor: var(--playerColor);
--settingsBtnColor: var(--playerColor);
--fullscreenBtnColor: var(--playerColor);
--settingsBg: #212121;
--settingsTextColor: var(--playerColor);
--settingsInputIconBg: #4b4b4b;
--settingsInputIconColor: var(--playerColor);
}
.theme-plyr {
--playerBg: #000000;
--playerColor: #626a76;
--playerBR: 10px;
--startLoaderColor: #ffffff;
--startBtnSize: 60px;
--startBtnBg: #01b2ff;
--startBtnIconColor: #ffffff;
--playPauseIconColor: #ffffff;
--forwardIconColor: #ffffff;
--backwardIconColor: #ffffff;
--sliderBg: rgba(255, 255, 255, 0.1);
--sliderThumbSize: 15px;
--sliderThumbColor: #01b2ff;
--sliderSeekTrackColor: #01b2ff;
--sliderLoadedTrackColor: #787672;
--currentTimeTextColor: #ffffff;
--durationTextColor: #ffffff;
--settingsBtnColor: #ffffff;
--fullscreenBtnColor: #ffffff;
--settingsBg: rgba(255, 255, 255, 0.95);
--settingsTextColor: #3d4049;
--settingsInputIconBg: #d9dadc;
--settingsInputIconColor: #01b2ff;
}
.csPlayer .csPlayer-container {
width: 100%;
aspect-ratio: 16/9;
overflow: hidden;
position: relative;
overflow: hidden;
border-radius: var(--playerBR);
border: none;
outline: none;
}
.csPlayer .csPlayer-container iframe {
width: 2400%;
height: 100%;
margin-left: -1149.95%;
border: none;
outline: none;
position: absolute;
}
.csPlayer .csPlayer-container span {
width: 100%;
height: 100%;
background-color: var(--playerBg);
background-image: none;
background-size: contain;
background-position: center;
background-repeat: no-repeat;
position: absolute;
z-index: 5;
display: flex;
align-items: center;
justify-content: center;
pointer-events: none;
}
.csPlayer .csPlayer-container span i {
display: block;
width: var(--startBtnSize);
height: var(--startBtnSize);
color: var(--startBtnIconColor);
display: flex;
align-items: center;
justify-content: center;
border-radius: 100%;
background: var(--startBtnBg);
font-size: calc(var(--startBtnSize) / 2.5);
}
.csPlayer-loading {
color: transparent !important;
background: transparent !important;
border: 5px solid var(--startLoaderColor) !important;
border-bottom-color: transparent !important;
animation: csPlayerSpin 1s linear infinite;
}
@keyframes csPlayerSpin {
0% {
transform: rotate(0deg);
}
100% {
transform: rotate(360deg);
}
}
.csPlayer .csPlayer-container span div {
width: 100%;
height: calc(50% - (var(--startBtnSize) / 2));
background: transparent !important;
outline: none !important;
border: none !important;
position: absolute;
pointer-events: auto;
}
.csPlayer .csPlayer-container span div:nth-of-type(1) {
top: 0;
}
.csPlayer .csPlayer-container span div:nth-of-type(2) {
bottom: 0;
}
.csPlayer .csPlayer-controls-box {
width: 100%;
height: 100%;
position: absolute;
z-index: 3;
background: transparent;
border-radius: var(--playerBR);
display: none;
align-items: flex-end;
}
.csPlayer-controls-open {
background: rgba(0, 0, 0, 0.5) !important;
}
.csPlayer-controls-open main {
transform: translate(-50%, -50%) scale(1) !important;
}
.csPlayer-controls-open .csPlayer-controls {
transform: scale(1) !important;
}
.csPlayer .csPlayer-controls-box main {
position: absolute;
transform: translate(-50%, -50%) scale(0);
transition: transform 0s ease-in-out;
top: 50%;
left: 50%;
display: flex;
align-items: center;
white-space: nowrap;
}
.csPlayer .csPlayer-controls-box main i {
font-size: 2rem;
padding: 8px;
border-radius: 100%;
}
.csPlayer .csPlayer-controls-box main i:active {
background: rgba(255, 255, 255, 0.1);
}
.csPlayer .csPlayer-controls-box main i.ti-rewind-backward-10 {
color: var(--backwardIconColor);
}
.csPlayer .csPlayer-controls-box main i.ti-rewind-forward-10 {
color: var(--forwardIconColor);
}
.csPlayer .csPlayer-controls-box main i.csPlayer-play-pause-btn {
color: var(--playPauseIconColor);
padding: 10px;
border: 2px solid var(--playPauseIconColor);
border-radius: 100%;
margin: 0 3.5rem;
}
.csPlayer .csPlayer-controls-box .csPlayer-controls {
width: 100%;
margin: 0 auto;
background: transparent;
display: flex;
align-items: center;
position: relative;
padding: 8px 10px;
transform: scale(0);
transition: transform 0s ease-in-out;
}
.csPlayer .csPlayer-controls-box .csPlayer-controls p {
font-size: 0.9rem;
margin: 0 0 0 4px;
}
.csPlayer .csPlayer-controls-box .csPlayer-controls p:nth-of-type(1) {
color: var(--currentTimeTextColor);
}
.csPlayer .csPlayer-controls-box .csPlayer-controls p:nth-of-type(2) {
color: var(--durationTextColor);
}
.csPlayer .csPlayer-controls-box .csPlayer-controls i {
font-size: 1.4rem;
margin: 0 0 0 6px;
padding: 4px;
border-radius: 6px;
}
.csPlayer .csPlayer-controls-box .csPlayer-controls i:active {
background: rgba(255, 255, 255, 0.1);
}
.csPlayer .csPlayer-controls-box .csPlayer-controls i.settingsBtn {
color: var(--settingsBtnColor);
}
.csPlayer .csPlayer-controls-box .csPlayer-controls i.fsBtn {
color: var(--fullscreenBtnColor);
}
.csPlayer .csPlayer-controls-box .csPlayer-controls div {
display: flex;
align-items: center;
white-space: nowrap;
width: 100%;
height: calc(var(--sliderThumbSize) / 3);
margin: 0 5px;
position: relative;
background: var(--sliderBg);
border-radius: calc(var(--sliderThumbSize) / 3);
}
.csPlayer .csPlayer-controls-box .csPlayer-controls div span {
height: 100%;
position: absolute;
background: var(--sliderLoadedTrackColor);
opacity: 0.5;
border-radius: calc(var(--sliderThumbSize) / 3);
}
.csPlayer .csPlayer-controls-box .csPlayer-controls div input {
width: 100%;
-webkit-appearance: none;
appearance: none;
position: absolute;
appearance: none;
background: linear-gradient(
to right,
var(--sliderSeekTrackColor) 0%,
transparent 0%
);
border-radius: calc(var(--sliderThumbSize) / 3);
}
.csPlayer .csPlayer-controls-box .csPlayer-controls div input:focus {
outline: none;
}
.csPlayer
.csPlayer-controls-box
.csPlayer-controls
div
input::-webkit-slider-runnable-track {
background: transparent;
height: calc(var(--sliderThumbSize) / 3);
}
.csPlayer
.csPlayer-controls-box
.csPlayer-controls
div
input::-webkit-slider-thumb {
-webkit-appearance: none;
appearance: none;
margin-top: calc(0px - (var(--sliderThumbSize) / 3));
background-color: var(--sliderThumbColor);
height: calc(var(--sliderThumbSize));
width: calc(var(--sliderThumbSize));
border-radius: 100%;
position: relative;
z-index: 2;
}
/* Firefox */
.csPlayer
.csPlayer-controls-box
.csPlayer-controls
div
input::-moz-range-track {
background: transparent;
height: calc(var(--sliderThumbSize) / 3);
}
.csPlayer
.csPlayer-controls-box
.csPlayer-controls
div
input::-moz-range-thumb {
margin-top: calc(0px - (var(--sliderThumbSize) / 3));
background-color: var(--sliderThumbColor);
height: calc(var(--sliderThumbSize));
width: calc(var(--sliderThumbSize));
border-radius: 100%;
position: relative;
z-index: 2;
}
.csPlayer .csPlayer-controls-box .csPlayer-settings-box {
width: 150px;
max-width: 200px;
position: absolute;
bottom: 40px;
right: 50px;
padding: 4px 10px;
background: var(--settingsBg);
border-radius: 5px;
display: none;
pointer-events: auto;
}
.csPlayer .csPlayer-controls-box .csPlayer-settings-box p {
width: 100%;
color: var(--settingsTextColor);
font-size: 14px;
display: flex;
align-items: center;
justify-content: flex-start;
padding: 8px 0;
}
.csPlayer .csPlayer-controls-box .csPlayer-settings-box p b {
font-weight: normal;
margin: 0 0 0 auto;
padding-left: 15px;
opacity: 0.6;
}
.csPlayer .csPlayer-controls-box .csPlayer-settings-box p i {
font-size: 16px;
opacity: 0.8;
}
.csPlayer .csPlayer-controls-box .csPlayer-settings-box span {
display: block;
max-height: 0px;
transition: max-height 0.3s ease-in-out;
overflow: hidden;
display: flex;
flex-direction: column;
}
.csPlayer .csPlayer-controls-box .csPlayer-settings-box span label {
width: 100%;
display: flex;
align-self: center;
font-size: 13px;
color: var(--settingsTextColor);
margin: 6px 0;
opacity: 0.8;
letter-spacing: 1px;
}
.csPlayer
.csPlayer-controls-box
.csPlayer-settings-box
span
label
input[type="radio"] {
-webkit-appearance: none;
appearance: none;
background: var(--settingsInputIconBg);
margin: 0;
width: 15px;
height: 15px;
border-radius: 100%;
margin-right: 6px;
opacity: 1;
}
.csPlayer
.csPlayer-controls-box
.csPlayer-settings-box
span
label
input[type="radio"]:checked {
border: 5px solid var(--settingsInputIconColor);
}

4
animex/src/icons/tabler-icons.min.css vendored Normal file

File diff suppressed because one or more lines are too long

Binary file not shown.

Binary file not shown.

Binary file not shown.

8
animex/src/mangadex.html Normal file
View File

@@ -0,0 +1,8 @@
<iframe
src="https://mangadex.org/"
width="100%"
height="100%"
frameborder="0"
allowfullscreen
title="MangaDex">
</iframe>

235
animex/sw.js Normal file
View File

@@ -0,0 +1,235 @@
/* sw.js
PWA offline service worker
- precaches core app shell + important pages
- runtime caches images/videos/pdf with limits
- navigation fallback to /offline.html
- supports skipWaiting and downloadOffline messages
*/
const CACHE_VERSION = '2025-11-20-8'; // bump when you change assets
const PRECACHE = `precache-${CACHE_VERSION}`;
const RUNTIME = `runtime-${CACHE_VERSION}`;
// Offline fallback page
const OFFLINE_URL = '/offline.html';
const PRECACHE_URLS = [
'/', // index
'/index.html',
'/Launch.html',
'/about.html',
'/in.html',
'/intro.html',
'/login.html',
'/manga.html',
'/library.html',
'/offline.html',
'/portal.html',
'/reader.html',
'/video_player.html',
'/pdf.html',
'/view.html',
'/search.html',
'/lists.html',
'/settings.html',
'/Resources/manifest.json',
'/Resources/styles.css',
'/Resources/manga.css',
'/Resources/series.css',
'/Resources/favicon.png',
'/Resources/Images/Launch.png',
'/Resources/Images/Launch_screen.png',
'/Resources/Images/logo-256.png',
'/Resources/Images/logo-512.png',
'/sw.js'
];
// Runtime cache limits
const MAX_IMAGE_ITEMS = 80;
const MAX_VIDEO_ITEMS = 15;
const MAX_PDF_ITEMS = 20;
/* Utility: trim cache to max items (LRU-ish by deleting oldest) */
async function trimCache(cacheName, maxItems) {
const cache = await caches.open(cacheName);
const keys = await cache.keys();
if (keys.length <= maxItems) return;
const removeCount = keys.length - maxItems;
for (let i = 0; i < removeCount; i++) {
await cache.delete(keys[i]);
}
}
/* Install: precache app shell */
self.addEventListener('install', event => {
self.skipWaiting(); // activate worker immediately (be careful with breaking changes)
event.waitUntil(
caches.open(PRECACHE)
.then(cache => cache.addAll(PRECACHE_URLS))
.catch(err => {
console.error('Precache failed:', err);
})
);
});
/* Activate: clean old caches */
self.addEventListener('activate', event => {
event.waitUntil((async () => {
const names = await caches.keys();
await Promise.all(
names.filter(name => name !== PRECACHE && name !== RUNTIME)
.map(name => caches.delete(name))
);
// Immediately take control of the pages
await self.clients.claim();
})());
});
/* Fetch handler */
self.addEventListener('fetch', event => {
const req = event.request;
const url = new URL(req.url);
// Only handle same-origin requests (adjust if assets are on a CDN)
const sameOrigin = url.origin === self.location.origin;
// 1) Navigation requests -> network-first, fallback to cache -> offline page
if (req.mode === 'navigate') {
event.respondWith(networkFirstFallbackToCache(req));
return;
}
// 2) API / JSON/XHR requests -> network-first (don't cache large dynamic responses)
if (sameOrigin && url.pathname.startsWith('/api')) {
event.respondWith(networkFirst(req));
return;
}
// 3) Images -> cache-first with size limit
if (req.destination === 'image' || /\.(?:png|jpg|jpeg|gif|webp|svg)$/i.test(url.pathname)) {
event.respondWith(cacheFirstWithRuntime(req, 'images-cache', MAX_IMAGE_ITEMS));
return;
}
// 4) Video files -> cache-first but avoid precaching; runtime cache with small limit
if (/\.(?:mp4|webm|m4v|mov)$/i.test(url.pathname)) {
event.respondWith(cacheFirstWithRuntime(req, 'videos-cache', MAX_VIDEO_ITEMS));
return;
}
// 5) PDFs and other documents -> cache-first with limit
if (/\.(?:pdf|epub|mobi)$/i.test(url.pathname) || req.destination === 'document') {
event.respondWith(cacheFirstWithRuntime(req, 'docs-cache', MAX_PDF_ITEMS));
return;
}
// 6) CSS/JS/font -> stale-while-revalidate strategy
if (/\.(?:js|css|woff2?|ttf|otf)$/i.test(url.pathname) || req.destination === 'script' || req.destination === 'style' || req.destination === 'font') {
event.respondWith(staleWhileRevalidate(req));
return;
}
// Default: try cache first then network
event.respondWith(
caches.match(req).then(cached => cached || fetch(req).catch(() => {
// if fetch failed and request is a navigation or HTML, show offline page
if (req.headers.get('accept') && req.headers.get('accept').includes('text/html')) {
return caches.match(OFFLINE_URL);
}
return new Response(null, { status: 503, statusText: 'Service Unavailable' });
}))
);
});
/* Strategies */
async function networkFirstFallbackToCache(request) {
try {
const response = await fetch(request);
// Put navigation responses in runtime cache for offline use
const cache = await caches.open(RUNTIME);
cache.put(request, response.clone()).catch(() => {});
return response;
} catch (err) {
// network failed -> try cache
const cached = await caches.match(request);
if (cached) return cached;
// finally fallback to offline page
return caches.match(OFFLINE_URL);
}
}
async function networkFirst(request) {
try {
const response = await fetch(request);
// Update runtime cache
const cache = await caches.open(RUNTIME);
cache.put(request, response.clone()).catch(() => {});
return response;
} catch (err) {
const cached = await caches.match(request);
if (cached) return cached;
throw err;
}
}
async function cacheFirstWithRuntime(request, cacheName, maxItems = 50) {
const cache = await caches.open(cacheName);
const cached = await cache.match(request);
if (cached) {
return cached;
}
try {
const response = await fetch(request);
// Only cache successful responses (200)
if (response && response.status === 200) {
cache.put(request, response.clone()).catch(() => {});
// trim if necessary
trimCache(cacheName, maxItems).catch(() => {});
}
return response;
} catch (err) {
// fallback to precache or offline
const fallback = await caches.match(request);
if (fallback) return fallback;
if (request.headers.get('accept') && request.headers.get('accept').includes('text/html')) {
return caches.match(OFFLINE_URL);
}
return new Response(null, { status: 503, statusText: 'Service Unavailable' });
}
}
async function staleWhileRevalidate(request) {
const cache = await caches.open(RUNTIME);
const cached = await cache.match(request);
const networkFetch = fetch(request).then(response => {
if (response && response.status === 200) {
cache.put(request, response.clone()).catch(() => {});
}
return response;
}).catch(() => null);
return cached || networkFetch;
}
/* Listen for messages from the page to trigger SW actions (skipWaiting, downloadOffline) */
self.addEventListener('message', event => {
const data = event.data;
if (!data) return;
if (data.type === 'SKIP_WAITING') {
self.skipWaiting();
}
if (data.type === 'DOWNLOAD_OFFLINE') {
// Cache urls that are missing from PRECACHE
downloadOffline();
}
});
/* Pre-cache any resources that aren't yet cached */
async function downloadOffline() {
const cache = await caches.open(PRECACHE);
const cachedRequests = await cache.keys();
const cachedUrls = cachedRequests.map(r => new URL(r.url).pathname);
const toCache = PRECACHE_URLS.filter(url => !cachedUrls.includes(url));
return cache.addAll(toCache);
}

2192
animex/video_player.html Normal file

File diff suppressed because it is too large Load Diff

611
animex/view.html Normal file
View File

@@ -0,0 +1,611 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0, user-scalable=no, viewport-fit=cover" />
<title>Episode Player</title>
<link rel="preconnect" href="https://fonts.googleapis.com" />
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&display=swap" rel="stylesheet" />
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.5.1/css/all.min.css"/>
<style>
:root {
--accent-color: #FF9500;
--background-color: #0A0A0A;
--surface-color: #141414;
--text-primary: #FFFFFF;
--text-secondary: #A0A0A0;
--border-color: rgba(255, 255, 255, 0.1);
--indicator-left: 4px;
--indicator-width: 0px;
}
html, body {
margin: 0;
padding: 0;
height: 100%;
width: 100%;
background: var(--background-color);
font-family: 'Inter', sans-serif;
color: var(--text-primary);
-webkit-tap-highlight-color: transparent;
overscroll-behavior-y: contain;
}
body {
display: flex;
flex-direction: column;
overflow-x: hidden;
}
/* --- Loading Screen --- */
#loading-container {
position: fixed; top: 0; left: 0; width: 100vw; height: 100vh;
z-index: 200; background-color: #000; background-size: cover;
background-position: center; display: flex; align-items: flex-end;
justify-content: center; transition: opacity 0.3s ease;
}
#loading-container::before {
content: ''; position: absolute; top: 0; left: 0; width: 100%;
height: 100%; background: linear-gradient(to top, rgba(0,0,0,1) 20%, rgba(0,0,0,0.5) 50%, rgba(0,0,0,0) 100%);
}
#loading-info {
position: relative; text-align: left; padding: 1.5rem 1.5rem 3rem;
max-width: 90%; max-height: 50%; overflow-y: auto; box-sizing: border-box;
}
#loading-info h2 { margin: 0 0 0.5rem 0; font-size: 1.5rem; }
#loading-info p { margin: 0; font-size: 0.9rem; color: var(--text-secondary); line-height: 1.5; }
.loader-spinner {
border: 4px solid rgba(255, 255, 255, 0.2); border-radius: 50%;
border-top: 4px solid var(--accent-color); width: 40px; height: 40px;
animation: spin 1s linear infinite; position: absolute; top: 50%; left: 50%;
transform: translate(-50%, -50%);
}
@keyframes spin { 100% { transform: translate(-50%, -50%) rotate(360deg); } }
/* --- Player & Content --- */
#player-container {
width: 100%;
aspect-ratio: 16 / 9;
background: #000;
flex-shrink: 0;
position: relative;
}
#player-container iframe {
width: 100%; height: 100%; border: none; background: #111;
}
#error-message {
color: #ff6b6b; text-align: center; padding: 2rem 1rem; font-size: 1.1rem;
display: flex; align-items: center; justify-content: center; height: 100%;
}
#content-container {
flex-grow: 1;
padding: 1rem 1.2rem;
background: var(--background-color);
display: flex;
flex-direction: column;
gap: 1rem;
}
/* --- Header Info --- */
#header-info {
text-align: left;
}
#series-title { font-size: 1.3rem; font-weight: 700; line-height: 1.3; }
#meta-line { font-size: 0.9rem; color: var(--text-secondary); margin-top: 0.25rem; }
/* --- Controls --- */
#controls-main {
display: grid;
grid-template-columns: 1fr auto auto;
gap: 0.8rem;
align-items: center;
}
.control-btn {
background: var(--surface-color);
border: 1px solid var(--border-color);
color: var(--text-secondary);
border-radius: 12px;
padding: 12px;
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
transition: background 0.2s, color 0.2s;
height: 44px; /* Match height of other controls */
width: 44px;
}
.control-btn:hover {
background: var(--border-color);
color: var(--text-primary);
}
#source-controls {
display: flex;
background-color: var(--surface-color);
border-radius: 12px;
padding: 5px;
border: 1px solid var(--border-color);
gap: 5px;
}
/* SUB/DUB Toggle */
.subdub-toggle {
position: relative; display: flex;
list-style: none; margin: 0; padding: 0;
flex-grow: 1;
}
.subdub-toggle::before {
content: ''; position: absolute; top: 0; left: var(--indicator-left);
width: var(--indicator-width); height: 100%;
background: var(--accent-color);
border-radius: 8px;
z-index: 1;
transition: all 0.4s cubic-bezier(0.68, -0.55, 0.265, 1.55);
}
.subdub-btn {
position: relative; /* Ensure z-index stacking context */
background: none; border: none; color: var(--text-secondary);
font-size: 0.9em; font-weight: 600;
padding: 8px 16px; /* Add horizontal padding for spacing */
border-radius: 8px; cursor: pointer;
outline: none; z-index: 2; transition: color 0.3s ease;
flex-grow: 1; text-align: center;
}
.subdub-btn.active { color: var(--text-primary); }
/* Source Selector */
.source-selector-wrapper {
position: relative;
flex-grow: 1;
}
#source-changer-select {
width: 100%;
background-color: transparent;
color: var(--text-primary);
border: none;
border-radius: 8px;
padding: 8px 28px 8px 12px;
font-size: 0.9em;
font-weight: 600;
font-family: 'Inter', sans-serif;
-webkit-appearance: none;
appearance: none;
cursor: pointer;
text-align: center;
text-align-last: center; /* For Firefox */
}
.source-selector-wrapper::after {
content: '';
position: absolute;
right: 10px;
top: 50%;
transform: translateY(-50%);
width: 0;
height: 0;
border-left: 5px solid transparent;
border-right: 5px solid transparent;
border-top: 5px solid var(--text-secondary);
pointer-events: none;
}
#source-changer-select:focus { outline: none; }
/* Next Episode Button */
#next-episode-btn {
display: block; background: var(--accent-color); color: #fff;
font-weight: 600; font-size: 0.95em; border: none; border-radius: 12px;
padding: 12px 20px; cursor: pointer; transition: background 0.2s;
white-space: nowrap;
}
#next-episode-btn:hover { background: #e6850a; }
#series-over-msg {
text-align: center; font-weight: 600; font-size: 0.95em;
color: var(--accent-color); opacity: 0.8; padding: 12px 0;
}
/* Synopsis */
#synopsis-container {
margin-top: 0.5rem;
}
#synopsis-container h4 {
margin: 0 0 0.5rem 0; font-weight: 600; opacity: 0.9;
}
#synopsis-container p {
font-size: 0.9em; line-height: 1.6; color: var(--text-secondary); margin: 0;
}
/* --- Landscape Mode --- */
@media (orientation: landscape) and (max-height: 600px) {
body { flex-direction: row; }
#player-container {
width: 100vw; height: 100vh;
position: fixed; top: 0; left: 0; z-index: 100;
}
#content-container {
display: none; /* Hide by default in landscape */
}
/* Add specific landscape controls if needed, or rely on player's native controls */
}
</style>
</head>
<body>
<div id="loading-container">
<div class="loader-spinner"></div>
<div id="loading-info">
<h2 id="loading-title"></h2>
<p id="loading-synopsis"></p>
</div>
</div>
<div id="player-container"></div>
<div id="content-container">
<div id="header-info">
<div id="series-title"></div>
<div id="meta-line">
<span>Type: <span id="series-type"></span></span> |
<span>Year: <span id="series-year"></span></span> |
<span>Episode: <span id="episode-number"></span></span>
</div>
</div>
<div id="controls-main">
<div id="source-controls">
<ul class="subdub-toggle" id="subdub-toggle">
<li><button class="subdub-btn active" id="sub-option-btn" type="button">SUB</button></li>
<li><button class="subdub-btn" id="dub-option-btn" type="button">DUB</button></li>
</ul>
<div class="source-selector-wrapper">
<select id="source-changer-select" name="source-changer"></select>
</div>
</div>
<button id="reload-iframe-btn" class="control-btn" title="Reload Player">
<i class="fas fa-sync-alt"></i>
</button>
<div id="next-episode-container"></div>
</div>
<div id="synopsis-container">
<h4>Synopsis</h4>
<p id="synopsis-text"></p>
</div>
</div>
<script>
document.addEventListener('DOMContentLoaded', async () => {
const params = new URLSearchParams(window.location.search);
const jikanId = params.get('id');
const episode = params.get('ep');
// DOM Elements
const playerContainer = document.getElementById('player-container');
const loadingContainer = document.getElementById('loading-container');
const subOptionBtn = document.getElementById('sub-option-btn');
const dubOptionBtn = document.getElementById('dub-option-btn');
const sourceChangerSelect = document.getElementById('source-changer-select');
const nextEpisodeContainer = document.getElementById('next-episode-container');
const synopsisText = document.getElementById('synopsis-text');
// App State
const serverIp = localStorage.getItem('extension_server_ip');
const profileId = localStorage.getItem("currentProfileId");
let currentProfile = null;
let userPref = localStorage.getItem('animeDubPref') || 'sub';
let currentModule = 'default';
let fetchedAnimeData = null; // Declare here to make it accessible
// --- Main Initialization ---
try {
// Initial Checks
if (!jikanId || !episode) {
throw new Error('Missing "id" or "ep" parameter.');
}
if (!serverIp) {
throw new Error('Extension server IP not set. Please configure it.');
}
loadingContainer.style.display = 'flex';
await fetchProfile();
fetchedAnimeData = await fetchAnimeData(); // Assign to the new variable
updateUI(fetchedAnimeData);
await logWatchHistory(jikanId, episode, fetchedAnimeData.title);
await populateSourceChanger();
if (userPref === 'dub') {
dubOptionBtn.classList.add('active');
subOptionBtn.classList.remove('active');
} else {
subOptionBtn.classList.add('active');
dubOptionBtn.classList.remove('active');
}
await loadIframe(userPref === 'dub', currentModule, fetchedAnimeData); // Pass fetchedAnimeData
setupEventListeners();
setTimeout(moveActiveIndicator, 150);
} catch (error) {
console.error("A critical error occurred during initialization:", error);
loadingContainer.style.display = 'none';
playerContainer.innerHTML = `<div id="error-message">Failed to load player: ${error.message}</div>`;
}
// --- Functions ---
async function fetchProfile() {
if (!profileId) return;
try {
const response = await fetch(`/profiles/${profileId}`);
if (response.ok) currentProfile = await response.json();
else console.warn("Could not load active profile.");
} catch (e) {
console.error("Error fetching profile:", e);
}
}
async function logWatchHistory(malId, episodeNumber, seriesTitle) {
if (!currentProfile || !seriesTitle || seriesTitle === 'Unknown') return;
const query = new URLSearchParams({
mal_id: malId,
episode_number: episodeNumber,
series_title: seriesTitle,
season_number: 1,
});
try {
await fetch(`/profiles/${currentProfile.id}/watch-history?${query.toString()}`, { method: 'POST' });
console.log(`Logged watch history for Ep. ${episodeNumber}.`);
} catch (error) {
console.error("Failed to log watch history:", error);
}
}
function moveActiveIndicator() {
const activeButton = document.querySelector('.subdub-btn.active');
if (activeButton) {
const indicator = document.querySelector('.subdub-toggle');
const listRect = indicator.getBoundingClientRect();
const buttonRect = activeButton.getBoundingClientRect();
const leftPosition = buttonRect.left - listRect.left;
indicator.style.setProperty('--indicator-left', `${leftPosition}px`);
indicator.style.setProperty('--indicator-width', `${activeButton.offsetWidth}px`);
}
}
async function fetchAnimeData() {
let title = 'Unknown', type = 'N/A', year = 'N/A', totalEpisodes = null, synopsis = 'No synopsis available.';
let episodeSynopsis = 'No episode synopsis available.', episodeThumbnail = null;
try {
const [jikanInfoResp, jikanEpisodesResp, kitsuMapResp] = await Promise.all([
fetch(`https://api.jikan.moe/v4/anime/${jikanId}`),
fetch(`https://api.jikan.moe/v4/anime/${jikanId}/episodes`),
fetch(`http://${serverIp}:7275/map/mal/${jikanId}`)
]);
if (jikanInfoResp.ok) {
const data = (await jikanInfoResp.json()).data || {};
title = data.title_english || data.title || 'Unknown';
type = data.type || 'N/A';
year = data.year || data.aired?.prop?.from?.year || 'N/A';
}
if (jikanEpisodesResp.ok) {
const data = await jikanEpisodesResp.json();
totalEpisodes = data.pagination?.last_visible_page > 1 ? null : data.data?.length;
}
if (kitsuMapResp.ok) {
const mapData = await kitsuMapResp.json();
const kitsuId = mapData.kitsu_id;
const [kitsuAnimeResp, kitsuEpisodeResp] = await Promise.all([
fetch(`https://kitsu.io/api/edge/anime/${kitsuId}`),
fetch(`https://kitsu.io/api/edge/anime/${kitsuId}/episodes?filter[number]=${episode}`)
]);
if (kitsuAnimeResp.ok) {
const kitsuData = await kitsuAnimeResp.json();
if (kitsuData && kitsuData.data && kitsuData.data.attributes) {
const attributes = kitsuData.data.attributes;
synopsis = attributes.synopsis || synopsis;
if (attributes.posterImage?.original) {
loadingContainer.style.backgroundImage = `url(${attributes.posterImage.original})`;
}
}
}
if (kitsuEpisodeResp.ok) {
const episodeData = await kitsuEpisodeResp.json();
if (episodeData.data?.length > 0 && episodeData.data[0].attributes) {
const episodeAttributes = episodeData.data[0].attributes;
episodeSynopsis = episodeAttributes.synopsis || episodeAttributes.description || episodeSynopsis;
episodeThumbnail = episodeAttributes.thumbnail?.original;
}
}
}
} catch (err) {
console.warn('Failed to fetch some anime data:', err);
// Return default data, but the main try/catch will handle the UI
}
return { title, type, year, totalEpisodes, synopsis, episodeSynopsis, episodeThumbnail };
}
async function populateSourceChanger() {
try {
const response = await fetch(`http://${serverIp}:7275/modules/streaming`);
if (!response.ok) throw new Error('Failed to fetch streaming modules');
const modules = await response.json();
sourceChangerSelect.innerHTML = '';
const defaultOption = document.createElement('option');
defaultOption.textContent = 'Default Source';
defaultOption.value = 'default';
sourceChangerSelect.appendChild(defaultOption);
if (modules.length > 0) {
modules.forEach(module => {
const option = document.createElement('option');
option.value = module.id;
option.textContent = module.name;
sourceChangerSelect.appendChild(option);
});
}
} catch (error) {
console.error('Could not populate source changer:', error);
const errorOption = document.createElement('option');
errorOption.textContent = 'Error loading';
errorOption.value = 'default';
errorOption.disabled = true;
sourceChangerSelect.innerHTML = '';
sourceChangerSelect.appendChild(errorOption);
}
}
function updateUI(data) {
const safeData = data || {};
document.getElementById('series-title').textContent = safeData.title || '—';
document.getElementById('series-type').textContent = safeData.type || 'N/A';
document.getElementById('series-year').textContent = safeData.year || 'N/A';
document.getElementById('episode-number').textContent = episode || 'N/A';
synopsisText.textContent = safeData.episodeSynopsis || 'No synopsis available.';
document.getElementById('loading-title').textContent = safeData.title || 'Loading...';
document.getElementById('loading-synopsis').textContent = safeData.episodeSynopsis || '';
if (safeData.episodeThumbnail) {
loadingContainer.style.backgroundImage = `url(${safeData.episodeThumbnail})`;
}
const currentEpNum = parseInt(episode, 10);
nextEpisodeContainer.innerHTML = ''; // Clear previous button/message
if (safeData.totalEpisodes && currentEpNum < safeData.totalEpisodes) {
const nextEpBtn = document.createElement('button');
nextEpBtn.id = 'next-episode-btn';
nextEpBtn.textContent = `Next →`;
nextEpBtn.addEventListener('click', () => {
params.set('ep', currentEpNum + 1);
window.location.search = params.toString();
});
nextEpisodeContainer.appendChild(nextEpBtn);
} else if (safeData.totalEpisodes && currentEpNum >= safeData.totalEpisodes) {
const seriesOverMsg = document.createElement('div');
seriesOverMsg.id = 'series-over-msg';
seriesOverMsg.textContent = 'Series Complete';
nextEpisodeContainer.appendChild(seriesOverMsg);
}
}
async function loadIframe(dub, preferModule = 'default', animeData) {
playerContainer.innerHTML = '';
// Don't show loading screen again if it's already visible
if (loadingContainer.style.display !== 'flex') {
loadingContainer.style.display = 'flex';
}
let iframeSrcURL = `http://${serverIp}:7275/iframe-src?mal_id=${jikanId}&episode=${episode}&dub=${dub}`;
if (preferModule && preferModule !== 'default') {
iframeSrcURL += `&prefer_module=${encodeURIComponent(preferModule)}`;
}
try {
const res = await fetch(iframeSrcURL);
if (!res.ok) {
if (res.status === 404 && preferModule !== 'default') {
console.warn(`Module '${preferModule}' failed with 404. Retrying with 'default' module.`);
// Clear previous error message if any
playerContainer.innerHTML = '';
// Retry with default module
await loadIframe(dub, 'default', animeData);
return; // Stop execution here as the retry is handled
} else {
throw new Error(`Proxy error ${res.status}`);
}
}
const json = await res.json();
if (!json.src) throw new Error('No embed source found from any module');
// Append to json.src
const playerUrl = new URL(json.src, window.location.origin);
playerUrl.searchParams.set('series_title', animeData.title);
playerUrl.searchParams.set('episode_num', episode); // Ensure episode is always passed explicitly
if (animeData.episodeThumbnail) {
playerUrl.searchParams.set('thumbnail_url', animeData.episodeThumbnail);
}
json.src = playerUrl.toString();
if (json.source_module) {
sourceChangerSelect.value = json.source_module;
currentModule = json.source_module;
}
const iframe = document.createElement('iframe');
iframe.setAttribute('sandbox', 'allow-scripts allow-same-origin allow-fullscreen');
iframe.setAttribute('src', json.src);
iframe.setAttribute('allowfullscreen', 'true');
iframe.setAttribute('scrolling', 'no');
playerContainer.appendChild(iframe);
// Hide the loading screen as soon as the iframe is added to the DOM.
// The iframe itself will show its own loading progress.
loadingContainer.style.display = 'none';
} catch (err) {
console.error('Error loading player:', err);
loadingContainer.style.display = 'none';
playerContainer.innerHTML = `<div id="error-message">Could not load player: ${err.message}. Please try another source or type.</div>`;
}
}
function setupEventListeners() {
const reloadIframeBtn = document.getElementById('reload-iframe-btn');
subOptionBtn.addEventListener('click', async () => {
if (userPref !== 'sub') {
userPref = 'sub';
localStorage.setItem('animeDubPref', 'sub');
subOptionBtn.classList.add('active');
dubOptionBtn.classList.remove('active');
moveActiveIndicator();
// FIX: Pass fetchedAnimeData as the 3rd argument
await loadIframe(false, currentModule, fetchedAnimeData);
}
});
dubOptionBtn.addEventListener('click', async () => {
if (userPref !== 'dub') {
userPref = 'dub';
localStorage.setItem('animeDubPref', 'dub');
dubOptionBtn.classList.add('active');
subOptionBtn.classList.remove('active');
moveActiveIndicator();
// FIX: Pass fetchedAnimeData as the 3rd argument
await loadIframe(true, currentModule, fetchedAnimeData);
}
});
sourceChangerSelect.addEventListener('change', async () => {
const selectedModule = sourceChangerSelect.value;
if (selectedModule !== currentModule) {
currentModule = selectedModule;
// FIX: Pass fetchedAnimeData as the 3rd argument
await loadIframe(userPref === 'dub', currentModule, fetchedAnimeData);
}
});
reloadIframeBtn.addEventListener('click', () => {
const iframe = playerContainer.querySelector('iframe');
if (iframe) {
iframe.src = iframe.src;
}
});
window.addEventListener('resize', () => setTimeout(moveActiveIndicator, 100));
window.addEventListener('message', (event) => {
if (!event.data || event.data.type !== 'animex-next-episode') return;
const currentEp = parseInt(episode, 10);
if (isNaN(currentEp)) return;
params.set('ep', currentEp + 1);
window.location.search = params.toString();
});
}
});
</script>
</body>
</html>

1054
app.py Normal file

File diff suppressed because it is too large Load Diff

BIN
data/.DS_Store vendored Normal file

Binary file not shown.

Binary file not shown.

After

Width:  |  Height:  |  Size: 160 KiB

View File

@@ -0,0 +1 @@
{"payload": {"entries": [], "season_groups": [], "extras": []}, "_timestamp": 1774832874.544356}

File diff suppressed because one or more lines are too long

View File

@@ -0,0 +1 @@
{"payload": {"data": [{"relation": "Sequel", "entry": [{"mal_id": 59177, "type": "anime", "name": "Kaijuu 8-gou 2nd Season", "url": "https://myanimelist.net/anime/59177/Kaijuu_8-gou_2nd_Season"}]}, {"relation": "Adaptation", "entry": [{"mal_id": 127907, "type": "manga", "name": "Kaijuu 8-gou", "url": "https://myanimelist.net/manga/127907/Kaijuu_8-gou"}]}, {"relation": "Side Story", "entry": [{"mal_id": 59490, "type": "anime", "name": "Kaijuu 8-gou: Hoshina no Kyuujitsu", "url": "https://myanimelist.net/anime/59490/Kaijuu_8-gou__Hoshina_no_Kyuujitsu"}]}, {"relation": "Summary", "entry": [{"mal_id": 59489, "type": "anime", "name": "Kaijuu 8-gou Movie", "url": "https://myanimelist.net/anime/59489/Kaijuu_8-gou_Movie"}]}, {"relation": "Other", "entry": [{"mal_id": 60480, "type": "anime", "name": "Minute! Kaijuu 8-gou", "url": "https://myanimelist.net/anime/60480/Minute_Kaijuu_8-gou"}, {"mal_id": 62390, "type": "anime", "name": "Kaijuu 8-gou The Game", "url": "https://myanimelist.net/anime/62390/Kaijuu_8-gou_The_Game"}]}]}, "_timestamp": 1766980137.040375, "_is_permanent": false}

File diff suppressed because one or more lines are too long

View File

@@ -0,0 +1 @@
{"payload": {"data": {"mal_id": 59489, "url": "https://myanimelist.net/anime/59489/Kaijuu_8-gou_Movie", "images": {"jpg": {"image_url": "https://cdn.myanimelist.net/images/anime/1127/146604.jpg", "small_image_url": "https://cdn.myanimelist.net/images/anime/1127/146604t.jpg", "large_image_url": "https://cdn.myanimelist.net/images/anime/1127/146604l.jpg"}, "webp": {"image_url": "https://cdn.myanimelist.net/images/anime/1127/146604.webp", "small_image_url": "https://cdn.myanimelist.net/images/anime/1127/146604t.webp", "large_image_url": "https://cdn.myanimelist.net/images/anime/1127/146604l.webp"}}, "trailer": {"youtube_id": null, "url": null, "embed_url": "https://www.youtube-nocookie.com/embed/eBivqKyjiqA?enablejsapi=1&wmode=opaque&autoplay=1", "images": {"image_url": null, "small_image_url": null, "medium_image_url": null, "large_image_url": null, "maximum_image_url": null}}, "approved": true, "titles": [{"type": "Default", "title": "Kaijuu 8-gou Movie"}, {"type": "Synonym", "title": "8Kaijuu"}, {"type": "Synonym", "title": "Monster #8"}, {"type": "Synonym", "title": "Kaiju No. Eight"}, {"type": "Synonym", "title": "Kaiju #8"}, {"type": "Japanese", "title": "\u602a\u73638\u53f7"}, {"type": "English", "title": "Kaiju No. 8: Mission Recon"}], "title": "Kaijuu 8-gou Movie", "title_english": "Kaiju No. 8: Mission Recon", "title_japanese": "\u602a\u73638\u53f7", "title_synonyms": ["8Kaijuu", "Monster #8", "Kaiju No. Eight", "Kaiju #8"], "type": "Movie", "source": "Manga", "episodes": 1, "status": "Finished Airing", "airing": false, "aired": {"from": "2025-03-28T00:00:00+00:00", "to": null, "prop": {"from": {"day": 28, "month": 3, "year": 2025}, "to": {"day": null, "month": null, "year": null}}, "string": "Mar 28, 2025"}, "duration": "1 hr 37 min", "rating": "PG-13 - Teens 13 or older", "score": 7.13, "scored_by": 7600, "rank": 3880, "popularity": 3711, "members": 45209, "favorites": 97, "synopsis": "In a Kaiju-filled Japan, Kafka Hibino works in monster disposal. After reuniting with his childhood friend Mina Ashiro, a rising star in the anti-Kaiju Defense Force, he decides to pursue his abandoned dream of joining the Force... when he suddenly transforms into the powerful \"Kaiju No. 8.\" With help from his junior colleague Reno Ichikawa, Kafka hides his identity while striving towards his life-long dream of passing the Defense Force exam and standing at Mina's side. But when mysterious intelligent Kaiju attack a Defense Force base, Kafka faces a crucial decision in a desperate situation...\n\n(Source: Official site)", "background": "", "season": null, "year": null, "broadcast": {"day": null, "time": null, "timezone": null, "string": null}, "producers": [], "licensors": [], "studios": [{"mal_id": 10, "type": "anime", "name": "Production I.G", "url": "https://myanimelist.net/anime/producer/10/Production_IG"}], "genres": [{"mal_id": 1, "type": "anime", "name": "Action", "url": "https://myanimelist.net/anime/genre/1/Action"}, {"mal_id": 10, "type": "anime", "name": "Fantasy", "url": "https://myanimelist.net/anime/genre/10/Fantasy"}, {"mal_id": 24, "type": "anime", "name": "Sci-Fi", "url": "https://myanimelist.net/anime/genre/24/Sci-Fi"}], "explicit_genres": [], "themes": [{"mal_id": 50, "type": "anime", "name": "Adult Cast", "url": "https://myanimelist.net/anime/genre/50/Adult_Cast"}, {"mal_id": 38, "type": "anime", "name": "Military", "url": "https://myanimelist.net/anime/genre/38/Military"}, {"mal_id": 82, "type": "anime", "name": "Urban Fantasy", "url": "https://myanimelist.net/anime/genre/82/Urban_Fantasy"}], "demographics": [{"mal_id": 27, "type": "anime", "name": "Shounen", "url": "https://myanimelist.net/anime/genre/27/Shounen"}]}}, "_timestamp": 1766980139.740022, "_is_permanent": true}

View File

@@ -0,0 +1 @@
{"payload": {"data": {"mal_id": 59490, "url": "https://myanimelist.net/anime/59490/Kaijuu_8-gou__Hoshina_no_Kyuujitsu", "images": {"jpg": {"image_url": "https://cdn.myanimelist.net/images/anime/1262/146724.jpg", "small_image_url": "https://cdn.myanimelist.net/images/anime/1262/146724t.jpg", "large_image_url": "https://cdn.myanimelist.net/images/anime/1262/146724l.jpg"}, "webp": {"image_url": "https://cdn.myanimelist.net/images/anime/1262/146724.webp", "small_image_url": "https://cdn.myanimelist.net/images/anime/1262/146724t.webp", "large_image_url": "https://cdn.myanimelist.net/images/anime/1262/146724l.webp"}}, "trailer": {"youtube_id": null, "url": null, "embed_url": "https://www.youtube-nocookie.com/embed/eBivqKyjiqA?enablejsapi=1&wmode=opaque&autoplay=1", "images": {"image_url": null, "small_image_url": null, "medium_image_url": null, "large_image_url": null, "maximum_image_url": null}}, "approved": true, "titles": [{"type": "Default", "title": "Kaijuu 8-gou: Hoshina no Kyuujitsu"}, {"type": "Japanese", "title": "\u602a\u73638\u53f7 \u4fdd\u79d1\u306e\u4f11\u65e5"}, {"type": "English", "title": "Kaiju No. 8: Hoshina's Day Off"}], "title": "Kaijuu 8-gou: Hoshina no Kyuujitsu", "title_english": "Kaiju No. 8: Hoshina's Day Off", "title_japanese": "\u602a\u73638\u53f7 \u4fdd\u79d1\u306e\u4f11\u65e5", "title_synonyms": [], "type": "Special", "source": "Manga", "episodes": 1, "status": "Finished Airing", "airing": false, "aired": {"from": "2025-03-28T00:00:00+00:00", "to": null, "prop": {"from": {"day": 28, "month": 3, "year": 2025}, "to": {"day": null, "month": null, "year": null}}, "string": "Mar 28, 2025"}, "duration": "23 min", "rating": "PG-13 - Teens 13 or older", "score": 6.79, "scored_by": 21045, "rank": 5593, "popularity": 3383, "members": 53245, "favorites": 107, "synopsis": "A day off... After spending so long training, Reno has forgotten what he's supposed to do with free time. But when he sees that Hoshina is up to something, he goes on a mission to tail him with Iharu!\n\n(Source: Official site)", "background": "", "season": null, "year": null, "broadcast": {"day": null, "time": null, "timezone": null, "string": null}, "producers": [], "licensors": [], "studios": [{"mal_id": 10, "type": "anime", "name": "Production I.G", "url": "https://myanimelist.net/anime/producer/10/Production_IG"}], "genres": [{"mal_id": 36, "type": "anime", "name": "Slice of Life", "url": "https://myanimelist.net/anime/genre/36/Slice_of_Life"}], "explicit_genres": [], "themes": [{"mal_id": 50, "type": "anime", "name": "Adult Cast", "url": "https://myanimelist.net/anime/genre/50/Adult_Cast"}], "demographics": [{"mal_id": 27, "type": "anime", "name": "Shounen", "url": "https://myanimelist.net/anime/genre/27/Shounen"}]}}, "_timestamp": 1766980138.213971, "_is_permanent": true}

View File

@@ -0,0 +1 @@
{"payload": {"data": {"mal_id": 52299, "url": "https://myanimelist.net/anime/52299/Ore_dake_Level_Up_na_Ken", "images": {"jpg": {"image_url": "https://cdn.myanimelist.net/images/anime/1801/142390.jpg", "small_image_url": "https://cdn.myanimelist.net/images/anime/1801/142390t.jpg", "large_image_url": "https://cdn.myanimelist.net/images/anime/1801/142390l.jpg"}, "webp": {"image_url": "https://cdn.myanimelist.net/images/anime/1801/142390.webp", "small_image_url": "https://cdn.myanimelist.net/images/anime/1801/142390t.webp", "large_image_url": "https://cdn.myanimelist.net/images/anime/1801/142390l.webp"}}, "trailer": {"youtube_id": null, "url": null, "embed_url": "https://www.youtube-nocookie.com/embed/1kQwjK4rGYg?enablejsapi=1&wmode=opaque&autoplay=1", "images": {"image_url": null, "small_image_url": null, "medium_image_url": null, "large_image_url": null, "maximum_image_url": null}}, "approved": true, "titles": [{"type": "Default", "title": "Ore dake Level Up na Ken"}, {"type": "Synonym", "title": "Na Honjaman Level Up"}, {"type": "Synonym", "title": "\ub098 \ud63c\uc790\ub9cc \ub808\ubca8\uc5c5"}, {"type": "Synonym", "title": "I Level Up Alone"}, {"type": "Japanese", "title": "\u4ffa\u3060\u3051\u30ec\u30d9\u30eb\u30a2\u30c3\u30d7\u306a\u4ef6"}, {"type": "English", "title": "Solo Leveling"}], "title": "Ore dake Level Up na Ken", "title_english": "Solo Leveling", "title_japanese": "\u4ffa\u3060\u3051\u30ec\u30d9\u30eb\u30a2\u30c3\u30d7\u306a\u4ef6", "title_synonyms": ["Na Honjaman Level Up", "\ub098 \ud63c\uc790\ub9cc \ub808\ubca8\uc5c5", "I Level Up Alone"], "type": "TV", "source": "Web manga", "episodes": 12, "status": "Finished Airing", "airing": false, "aired": {"from": "2024-01-07T00:00:00+00:00", "to": "2024-03-31T00:00:00+00:00", "prop": {"from": {"day": 7, "month": 1, "year": 2024}, "to": {"day": 31, "month": 3, "year": 2024}}, "string": "Jan 7, 2024 to Mar 31, 2024"}, "duration": "23 min per ep", "rating": "R - 17+ (violence & profanity)", "score": 8.2, "scored_by": 660377, "rank": 417, "popularity": 166, "members": 1071559, "favorites": 19488, "synopsis": "Humanity was caught at a precipice a decade ago when the first gates\u2014portals linked with other dimensions that harbor monsters immune to conventional weaponry\u2014emerged around the world. Alongside the appearance of the gates, various humans were transformed into hunters and bestowed superhuman abilities. Responsible for entering the gates and clearing the dungeons within, many hunters chose to form guilds to secure their livelihoods.\n\nSung Jin-Woo is an E-rank hunter dubbed as the weakest hunter of all mankind. While exploring a supposedly safe dungeon, he and his party encounter an unusual tunnel leading to a deeper area. Enticed by the prospect of treasure, the group presses forward, only to be confronted with horrors beyond their imagination. Miraculously, Jin-Woo survives the incident and soon finds that he now has access to an interface visible only to him. This mysterious system promises him the power he has long dreamed of\u2014but everything comes at a price.\n\n[Written by MAL Rewrite]", "background": "Ore dake Level Up na Ken was released on Blu-ray & DVD in four volumes from March 27, 2024, to June 26, 2024.", "season": "winter", "year": 2024, "broadcast": {"day": "Sundays", "time": "00:00", "timezone": "Asia/Tokyo", "string": "Sundays at 00:00 (JST)"}, "producers": [{"mal_id": 17, "type": "anime", "name": "Aniplex", "url": "https://myanimelist.net/anime/producer/17/Aniplex"}, {"mal_id": 1468, "type": "anime", "name": "Crunchyroll", "url": "https://myanimelist.net/anime/producer/1468/Crunchyroll"}, {"mal_id": 2837, "type": "anime", "name": "Netmarble", "url": "https://myanimelist.net/anime/producer/2837/Netmarble"}, {"mal_id": 2839, "type": "anime", "name": "Kakao piccoma", "url": "https://myanimelist.net/anime/producer/2839/Kakao_piccoma"}, {"mal_id": 2872, "type": "anime", "name": "D&C Media", "url": "https://myanimelist.net/anime/producer/2872/D_C_Media"}], "licensors": [], "studios": [{"mal_id": 56, "type": "anime", "name": "A-1 Pictures", "url": "https://myanimelist.net/anime/producer/56/A-1_Pictures"}], "genres": [{"mal_id": 1, "type": "anime", "name": "Action", "url": "https://myanimelist.net/anime/genre/1/Action"}, {"mal_id": 2, "type": "anime", "name": "Adventure", "url": "https://myanimelist.net/anime/genre/2/Adventure"}, {"mal_id": 10, "type": "anime", "name": "Fantasy", "url": "https://myanimelist.net/anime/genre/10/Fantasy"}], "explicit_genres": [], "themes": [{"mal_id": 50, "type": "anime", "name": "Adult Cast", "url": "https://myanimelist.net/anime/genre/50/Adult_Cast"}, {"mal_id": 82, "type": "anime", "name": "Urban Fantasy", "url": "https://myanimelist.net/anime/genre/82/Urban_Fantasy"}], "demographics": []}}, "_timestamp": 1766977263.0008268, "_is_permanent": true}

View File

@@ -0,0 +1 @@
{"payload": {"data": {"mal_id": 56243, "url": "https://myanimelist.net/anime/56243/Jujutsu_Kaisen_2nd_Season_Recaps", "images": {"jpg": {"image_url": "https://cdn.myanimelist.net/images/anime/1190/137716.jpg", "small_image_url": "https://cdn.myanimelist.net/images/anime/1190/137716t.jpg", "large_image_url": "https://cdn.myanimelist.net/images/anime/1190/137716l.jpg"}, "webp": {"image_url": "https://cdn.myanimelist.net/images/anime/1190/137716.webp", "small_image_url": "https://cdn.myanimelist.net/images/anime/1190/137716t.webp", "large_image_url": "https://cdn.myanimelist.net/images/anime/1190/137716l.webp"}}, "trailer": {"youtube_id": null, "url": null, "embed_url": null, "images": {"image_url": null, "small_image_url": null, "medium_image_url": null, "large_image_url": null, "maximum_image_url": null}}, "approved": true, "titles": [{"type": "Default", "title": "Jujutsu Kaisen 2nd Season Recaps"}, {"type": "Synonym", "title": "Jujutsu Kaisen Season 2 + Movie Recap"}, {"type": "Japanese", "title": "\u546a\u8853\u5efb\u6226"}, {"type": "English", "title": "Jujutsu Kaisen Season 2 Recaps"}], "title": "Jujutsu Kaisen 2nd Season Recaps", "title_english": "Jujutsu Kaisen Season 2 Recaps", "title_japanese": "\u546a\u8853\u5efb\u6226", "title_synonyms": ["Jujutsu Kaisen Season 2 + Movie Recap"], "type": "TV Special", "source": "Manga", "episodes": 2, "status": "Finished Airing", "airing": false, "aired": {"from": "2023-08-11T00:00:00+00:00", "to": "2023-08-18T00:00:00+00:00", "prop": {"from": {"day": 11, "month": 8, "year": 2023}, "to": {"day": 18, "month": 8, "year": 2023}}, "string": "Aug 11, 2023 to Aug 18, 2023"}, "duration": "24 min per ep", "rating": "R - 17+ (violence & profanity)", "score": 7.7, "scored_by": 14761, "rank": 1341, "popularity": 4362, "members": 32831, "favorites": 213, "synopsis": "Recap episodes of Jujutsu Kaisen, Jujutsu Kaisen 0, and the first five episodes of Jujutsu Kaisen 2nd Season.", "background": "", "season": null, "year": null, "broadcast": {"day": null, "time": null, "timezone": null, "string": null}, "producers": [{"mal_id": 1143, "type": "anime", "name": "TOHO animation", "url": "https://myanimelist.net/anime/producer/1143/TOHO_animation"}], "licensors": [], "studios": [{"mal_id": 569, "type": "anime", "name": "MAPPA", "url": "https://myanimelist.net/anime/producer/569/MAPPA"}], "genres": [{"mal_id": 1, "type": "anime", "name": "Action", "url": "https://myanimelist.net/anime/genre/1/Action"}, {"mal_id": 37, "type": "anime", "name": "Supernatural", "url": "https://myanimelist.net/anime/genre/37/Supernatural"}], "explicit_genres": [], "themes": [{"mal_id": 23, "type": "anime", "name": "School", "url": "https://myanimelist.net/anime/genre/23/School"}], "demographics": [{"mal_id": 27, "type": "anime", "name": "Shounen", "url": "https://myanimelist.net/anime/genre/27/Shounen"}]}}, "_timestamp": 1766968803.42492, "_is_permanent": true}

View File

@@ -0,0 +1 @@
{"payload": {"data": {"mal_id": 40748, "url": "https://myanimelist.net/anime/40748/Jujutsu_Kaisen", "images": {"jpg": {"image_url": "https://cdn.myanimelist.net/images/anime/1171/109222.jpg", "small_image_url": "https://cdn.myanimelist.net/images/anime/1171/109222t.jpg", "large_image_url": "https://cdn.myanimelist.net/images/anime/1171/109222l.jpg"}, "webp": {"image_url": "https://cdn.myanimelist.net/images/anime/1171/109222.webp", "small_image_url": "https://cdn.myanimelist.net/images/anime/1171/109222t.webp", "large_image_url": "https://cdn.myanimelist.net/images/anime/1171/109222l.webp"}}, "trailer": {"youtube_id": null, "url": null, "embed_url": "https://www.youtube-nocookie.com/embed/4A_X-Dvl0ws?enablejsapi=1&wmode=opaque&autoplay=1", "images": {"image_url": null, "small_image_url": null, "medium_image_url": null, "large_image_url": null, "maximum_image_url": null}}, "approved": true, "titles": [{"type": "Default", "title": "Jujutsu Kaisen"}, {"type": "Synonym", "title": "Sorcery Fight"}, {"type": "Synonym", "title": "JJK"}, {"type": "Japanese", "title": "\u546a\u8853\u5efb\u6226"}, {"type": "English", "title": "Jujutsu Kaisen"}, {"type": "German", "title": "Jujutsu Kaisen"}, {"type": "Spanish", "title": "Jujutsu Kaisen"}, {"type": "French", "title": "Jujutsu Kaisen"}], "title": "Jujutsu Kaisen", "title_english": "Jujutsu Kaisen", "title_japanese": "\u546a\u8853\u5efb\u6226", "title_synonyms": ["Sorcery Fight", "JJK"], "type": "TV", "source": "Manga", "episodes": 24, "status": "Finished Airing", "airing": false, "aired": {"from": "2020-10-03T00:00:00+00:00", "to": "2021-03-27T00:00:00+00:00", "prop": {"from": {"day": 3, "month": 10, "year": 2020}, "to": {"day": 27, "month": 3, "year": 2021}}, "string": "Oct 3, 2020 to Mar 27, 2021"}, "duration": "23 min per ep", "rating": "R - 17+ (violence & profanity)", "score": 8.52, "scored_by": 1927431, "rank": 143, "popularity": 13, "members": 2928659, "favorites": 94650, "synopsis": "Idly indulging in baseless paranormal activities with the Occult Club, high schooler Yuuji Itadori spends his days at either the clubroom or the hospital, where he visits his bedridden grandfather. However, this leisurely lifestyle soon takes a turn for the strange when he unknowingly encounters a cursed item. Triggering a chain of supernatural occurrences, Yuuji finds himself suddenly thrust into the world of Curses\u2014dreadful beings formed from human malice and negativity\u2014after swallowing the said item, revealed to be a finger belonging to the demon Sukuna Ryoumen, the King of Curses.\n\nYuuji experiences first-hand the threat these Curses pose to society as he discovers his own newfound powers. Introduced to the Tokyo Prefectural Jujutsu High School, he begins to walk down a path from which he cannot return\u2014the path of a Jujutsu sorcerer.\n\n[Written by MAL Rewrite]", "background": "Winner of the Anime of the Year (TV Series) at the 2022 Tokyo Anime Award Festival (TAAF).", "season": "fall", "year": 2020, "broadcast": {"day": "Saturdays", "time": "01:25", "timezone": "Asia/Tokyo", "string": "Saturdays at 01:25 (JST)"}, "producers": [{"mal_id": 143, "type": "anime", "name": "Mainichi Broadcasting System", "url": "https://myanimelist.net/anime/producer/143/Mainichi_Broadcasting_System"}, {"mal_id": 1143, "type": "anime", "name": "TOHO animation", "url": "https://myanimelist.net/anime/producer/1143/TOHO_animation"}, {"mal_id": 1365, "type": "anime", "name": "Shueisha", "url": "https://myanimelist.net/anime/producer/1365/Shueisha"}, {"mal_id": 1856, "type": "anime", "name": "dugout", "url": "https://myanimelist.net/anime/producer/1856/dugout"}, {"mal_id": 2260, "type": "anime", "name": "Sumzap", "url": "https://myanimelist.net/anime/producer/2260/Sumzap"}], "licensors": [{"mal_id": 119, "type": "anime", "name": "VIZ Media", "url": "https://myanimelist.net/anime/producer/119/VIZ_Media"}], "studios": [{"mal_id": 569, "type": "anime", "name": "MAPPA", "url": "https://myanimelist.net/anime/producer/569/MAPPA"}], "genres": [{"mal_id": 1, "type": "anime", "name": "Action", "url": "https://myanimelist.net/anime/genre/1/Action"}, {"mal_id": 46, "type": "anime", "name": "Award Winning", "url": "https://myanimelist.net/anime/genre/46/Award_Winning"}, {"mal_id": 37, "type": "anime", "name": "Supernatural", "url": "https://myanimelist.net/anime/genre/37/Supernatural"}], "explicit_genres": [], "themes": [{"mal_id": 23, "type": "anime", "name": "School", "url": "https://myanimelist.net/anime/genre/23/School"}], "demographics": [{"mal_id": 27, "type": "anime", "name": "Shounen", "url": "https://myanimelist.net/anime/genre/27/Shounen"}]}}, "_timestamp": 1766968781.25934, "_is_permanent": true}

View File

@@ -0,0 +1 @@
{"payload": {"data": {"mal_id": 58567, "url": "https://myanimelist.net/anime/58567/Ore_dake_Level_Up_na_Ken_Season_2__Arise_from_the_Shadow", "images": {"jpg": {"image_url": "https://cdn.myanimelist.net/images/anime/1448/147351.jpg", "small_image_url": "https://cdn.myanimelist.net/images/anime/1448/147351t.jpg", "large_image_url": "https://cdn.myanimelist.net/images/anime/1448/147351l.jpg"}, "webp": {"image_url": "https://cdn.myanimelist.net/images/anime/1448/147351.webp", "small_image_url": "https://cdn.myanimelist.net/images/anime/1448/147351t.webp", "large_image_url": "https://cdn.myanimelist.net/images/anime/1448/147351l.webp"}}, "trailer": {"youtube_id": null, "url": null, "embed_url": "https://www.youtube-nocookie.com/embed/GDMXGzjJzS4?enablejsapi=1&wmode=opaque&autoplay=1", "images": {"image_url": null, "small_image_url": null, "medium_image_url": null, "large_image_url": null, "maximum_image_url": null}}, "approved": true, "titles": [{"type": "Default", "title": "Ore dake Level Up na Ken Season 2: Arise from the Shadow"}, {"type": "Synonym", "title": "Solo Leveling Second Season"}, {"type": "Japanese", "title": "\u4ffa\u3060\u3051\u30ec\u30d9\u30eb\u30a2\u30c3\u30d7\u306a\u4ef6 Season 2 -Arise from the Shadow-"}, {"type": "English", "title": "Solo Leveling Season 2: Arise from the Shadow"}], "title": "Ore dake Level Up na Ken Season 2: Arise from the Shadow", "title_english": "Solo Leveling Season 2: Arise from the Shadow", "title_japanese": "\u4ffa\u3060\u3051\u30ec\u30d9\u30eb\u30a2\u30c3\u30d7\u306a\u4ef6 Season 2 -Arise from the Shadow-", "title_synonyms": ["Solo Leveling Second Season"], "type": "TV", "source": "Web manga", "episodes": 13, "status": "Finished Airing", "airing": false, "aired": {"from": "2025-01-05T00:00:00+00:00", "to": "2025-03-30T00:00:00+00:00", "prop": {"from": {"day": 5, "month": 1, "year": 2025}, "to": {"day": 30, "month": 3, "year": 2025}}, "string": "Jan 5, 2025 to Mar 30, 2025"}, "duration": "23 min per ep", "rating": "R - 17+ (violence & profanity)", "score": 8.6, "scored_by": 449733, "rank": 107, "popularity": 324, "members": 711651, "favorites": 11007, "synopsis": "Sung Jin-Woo, dubbed the weakest hunter of all mankind, grows stronger by the day with the supernatural powers he has gained. However, keeping his skills hidden becomes more difficult as dungeon-related incidents pile up around him.\n\nWhen Jin-Woo and a few other low-ranked hunters are the only survivors of a dungeon that turns out to be a bigger challenge than initially expected, he draws attention once again, and hunter guilds take an increased interest in him. Meanwhile, a strange hunter who has been lost for ten years returns with a dire warning about an upcoming catastrophic event. As the calamity looms closer, Jin-Woo must continue leveling up to make sure nothing stops him from reaching his ultimate goal\u2014saving the life of his mother.\n\n[Written by MAL Rewrite]", "background": "", "season": "winter", "year": 2025, "broadcast": {"day": "Sundays", "time": "00:00", "timezone": "Asia/Tokyo", "string": "Sundays at 00:00 (JST)"}, "producers": [{"mal_id": 17, "type": "anime", "name": "Aniplex", "url": "https://myanimelist.net/anime/producer/17/Aniplex"}, {"mal_id": 1468, "type": "anime", "name": "Crunchyroll", "url": "https://myanimelist.net/anime/producer/1468/Crunchyroll"}, {"mal_id": 1858, "type": "anime", "name": "Sonilude", "url": "https://myanimelist.net/anime/producer/1858/Sonilude"}, {"mal_id": 2837, "type": "anime", "name": "Netmarble", "url": "https://myanimelist.net/anime/producer/2837/Netmarble"}, {"mal_id": 2839, "type": "anime", "name": "Kakao piccoma", "url": "https://myanimelist.net/anime/producer/2839/Kakao_piccoma"}, {"mal_id": 2872, "type": "anime", "name": "D&C Media", "url": "https://myanimelist.net/anime/producer/2872/D_C_Media"}], "licensors": [], "studios": [{"mal_id": 56, "type": "anime", "name": "A-1 Pictures", "url": "https://myanimelist.net/anime/producer/56/A-1_Pictures"}], "genres": [{"mal_id": 1, "type": "anime", "name": "Action", "url": "https://myanimelist.net/anime/genre/1/Action"}, {"mal_id": 2, "type": "anime", "name": "Adventure", "url": "https://myanimelist.net/anime/genre/2/Adventure"}, {"mal_id": 10, "type": "anime", "name": "Fantasy", "url": "https://myanimelist.net/anime/genre/10/Fantasy"}], "explicit_genres": [], "themes": [{"mal_id": 50, "type": "anime", "name": "Adult Cast", "url": "https://myanimelist.net/anime/genre/50/Adult_Cast"}, {"mal_id": 82, "type": "anime", "name": "Urban Fantasy", "url": "https://myanimelist.net/anime/genre/82/Urban_Fantasy"}], "demographics": []}}, "_timestamp": 1766977263.4462872, "_is_permanent": true}

File diff suppressed because one or more lines are too long

View File

@@ -0,0 +1 @@
{"payload": {"data": {"mal_id": 59654, "url": "https://myanimelist.net/anime/59654/Jujutsu_Kaisen__Kaigyoku_Gyokusetsu", "images": {"jpg": {"image_url": "https://cdn.myanimelist.net/images/anime/1999/147023.jpg", "small_image_url": "https://cdn.myanimelist.net/images/anime/1999/147023t.jpg", "large_image_url": "https://cdn.myanimelist.net/images/anime/1999/147023l.jpg"}, "webp": {"image_url": "https://cdn.myanimelist.net/images/anime/1999/147023.webp", "small_image_url": "https://cdn.myanimelist.net/images/anime/1999/147023t.webp", "large_image_url": "https://cdn.myanimelist.net/images/anime/1999/147023l.webp"}}, "trailer": {"youtube_id": null, "url": null, "embed_url": "https://www.youtube-nocookie.com/embed/X1ZWOFHHfW4?enablejsapi=1&wmode=opaque&autoplay=1", "images": {"image_url": null, "small_image_url": null, "medium_image_url": null, "large_image_url": null, "maximum_image_url": null}}, "approved": true, "titles": [{"type": "Default", "title": "Jujutsu Kaisen: Kaigyoku/Gyokusetsu"}, {"type": "Japanese", "title": "\u546a\u8853\u5efb\u6226 \u61d0\u7389\u30fb\u7389\u6298"}, {"type": "English", "title": "Jujutsu Kaisen: Hidden Inventory/Premature Death"}], "title": "Jujutsu Kaisen: Kaigyoku/Gyokusetsu", "title_english": "Jujutsu Kaisen: Hidden Inventory/Premature Death", "title_japanese": "\u546a\u8853\u5efb\u6226 \u61d0\u7389\u30fb\u7389\u6298", "title_synonyms": [], "type": "Movie", "source": "Manga", "episodes": 1, "status": "Finished Airing", "airing": false, "aired": {"from": "2025-05-30T00:00:00+00:00", "to": null, "prop": {"from": {"day": 30, "month": 5, "year": 2025}, "to": {"day": null, "month": null, "year": null}}, "string": "May 30, 2025"}, "duration": "1 hr 50 min", "rating": "R - 17+ (violence & profanity)", "score": 8.11, "scored_by": 6079, "rank": 540, "popularity": 3447, "members": 51490, "favorites": 256, "synopsis": "A compilation movie of Jujutsu Kaisen's Hidden Inventory and Premature Death arc.", "background": "", "season": null, "year": null, "broadcast": {"day": null, "time": null, "timezone": null, "string": null}, "producers": [{"mal_id": 1856, "type": "anime", "name": "dugout", "url": "https://myanimelist.net/anime/producer/1856/dugout"}], "licensors": [{"mal_id": 783, "type": "anime", "name": "GKIDS", "url": "https://myanimelist.net/anime/producer/783/GKIDS"}], "studios": [{"mal_id": 569, "type": "anime", "name": "MAPPA", "url": "https://myanimelist.net/anime/producer/569/MAPPA"}], "genres": [{"mal_id": 1, "type": "anime", "name": "Action", "url": "https://myanimelist.net/anime/genre/1/Action"}, {"mal_id": 37, "type": "anime", "name": "Supernatural", "url": "https://myanimelist.net/anime/genre/37/Supernatural"}], "explicit_genres": [], "themes": [{"mal_id": 58, "type": "anime", "name": "Gore", "url": "https://myanimelist.net/anime/genre/58/Gore"}, {"mal_id": 23, "type": "anime", "name": "School", "url": "https://myanimelist.net/anime/genre/23/School"}], "demographics": [{"mal_id": 27, "type": "anime", "name": "Shounen", "url": "https://myanimelist.net/anime/genre/27/Shounen"}]}}, "_timestamp": 1766968803.421586, "_is_permanent": true}

View File

@@ -0,0 +1 @@
{"payload": {"data": {"mal_id": 57658, "url": "https://myanimelist.net/anime/57658/Jujutsu_Kaisen__Shimetsu_Kaiyuu_-_Zenpen", "images": {"jpg": {"image_url": "https://cdn.myanimelist.net/images/anime/1127/154488.jpg", "small_image_url": "https://cdn.myanimelist.net/images/anime/1127/154488t.jpg", "large_image_url": "https://cdn.myanimelist.net/images/anime/1127/154488l.jpg"}, "webp": {"image_url": "https://cdn.myanimelist.net/images/anime/1127/154488.webp", "small_image_url": "https://cdn.myanimelist.net/images/anime/1127/154488t.webp", "large_image_url": "https://cdn.myanimelist.net/images/anime/1127/154488l.webp"}}, "trailer": {"youtube_id": null, "url": null, "embed_url": "https://www.youtube-nocookie.com/embed/ruX3rIj3--w?enablejsapi=1&wmode=opaque&autoplay=1", "images": {"image_url": null, "small_image_url": null, "medium_image_url": null, "large_image_url": null, "maximum_image_url": null}}, "approved": true, "titles": [{"type": "Default", "title": "Jujutsu Kaisen: Shimetsu Kaiyuu - Zenpen"}, {"type": "Synonym", "title": "Jujutsu Kaisen 3rd Season"}, {"type": "Japanese", "title": "\u546a\u8853\u5efb\u6226 \u300c\u6b7b\u6ec5\u56de\u6e38 \u524d\u7de8\u300d"}, {"type": "English", "title": "Jujutsu Kaisen: The Culling Game Part 1"}], "title": "Jujutsu Kaisen: Shimetsu Kaiyuu - Zenpen", "title_english": "Jujutsu Kaisen: The Culling Game Part 1", "title_japanese": "\u546a\u8853\u5efb\u6226 \u300c\u6b7b\u6ec5\u56de\u6e38 \u524d\u7de8\u300d", "title_synonyms": ["Jujutsu Kaisen 3rd Season"], "type": "TV", "source": "Manga", "episodes": null, "status": "Not yet aired", "airing": false, "aired": {"from": "2026-01-09T00:00:00+00:00", "to": null, "prop": {"from": {"day": 9, "month": 1, "year": 2026}, "to": {"day": null, "month": null, "year": null}}, "string": "Jan 9, 2026 to ?"}, "duration": "Unknown", "rating": "R - 17+ (violence & profanity)", "score": null, "scored_by": null, "rank": null, "popularity": 1383, "members": 196539, "favorites": 889, "synopsis": "Sequel to Jujutsu Kaisen 2nd Season.", "background": "", "season": "winter", "year": 2026, "broadcast": {"day": "Fridays", "time": "00:26", "timezone": "Asia/Tokyo", "string": "Fridays at 00:26 (JST)"}, "producers": [{"mal_id": 1143, "type": "anime", "name": "TOHO animation", "url": "https://myanimelist.net/anime/producer/1143/TOHO_animation"}, {"mal_id": 1856, "type": "anime", "name": "dugout", "url": "https://myanimelist.net/anime/producer/1856/dugout"}], "licensors": [], "studios": [{"mal_id": 569, "type": "anime", "name": "MAPPA", "url": "https://myanimelist.net/anime/producer/569/MAPPA"}], "genres": [{"mal_id": 1, "type": "anime", "name": "Action", "url": "https://myanimelist.net/anime/genre/1/Action"}, {"mal_id": 37, "type": "anime", "name": "Supernatural", "url": "https://myanimelist.net/anime/genre/37/Supernatural"}], "explicit_genres": [], "themes": [{"mal_id": 23, "type": "anime", "name": "School", "url": "https://myanimelist.net/anime/genre/23/School"}], "demographics": [{"mal_id": 27, "type": "anime", "name": "Shounen", "url": "https://myanimelist.net/anime/genre/27/Shounen"}]}}, "_timestamp": 1767246613.554377, "_is_permanent": false}

View File

@@ -0,0 +1 @@
{"payload": {"data": [{"relation": "Prequel", "entry": [{"mal_id": 48583, "type": "anime", "name": "Shingeki no Kyojin: The Final Season Part 2", "url": "https://myanimelist.net/anime/48583/Shingeki_no_Kyojin__The_Final_Season_Part_2"}]}, {"relation": "Adaptation", "entry": [{"mal_id": 23390, "type": "manga", "name": "Shingeki no Kyojin", "url": "https://myanimelist.net/manga/23390/Shingeki_no_Kyojin"}]}, {"relation": "Summary", "entry": [{"mal_id": 59571, "type": "anime", "name": "Shingeki no Kyojin Movie: Kanketsu-hen - The Last Attack", "url": "https://myanimelist.net/anime/59571/Shingeki_no_Kyojin_Movie__Kanketsu-hen_-_The_Last_Attack"}]}]}, "_timestamp": 1767291338.341491, "_is_permanent": false}

View File

@@ -0,0 +1 @@
{"payload": {"data": {"mal_id": 49627, "url": "https://myanimelist.net/anime/49627/Shingeki_no_Kyojin__The_Final_Season_Specials", "images": {"jpg": {"image_url": "https://cdn.myanimelist.net/images/anime/1470/117265.jpg", "small_image_url": "https://cdn.myanimelist.net/images/anime/1470/117265t.jpg", "large_image_url": "https://cdn.myanimelist.net/images/anime/1470/117265l.jpg"}, "webp": {"image_url": "https://cdn.myanimelist.net/images/anime/1470/117265.webp", "small_image_url": "https://cdn.myanimelist.net/images/anime/1470/117265t.webp", "large_image_url": "https://cdn.myanimelist.net/images/anime/1470/117265l.webp"}}, "trailer": {"youtube_id": null, "url": null, "embed_url": null, "images": {"image_url": null, "small_image_url": null, "medium_image_url": null, "large_image_url": null, "maximum_image_url": null}}, "approved": true, "titles": [{"type": "Default", "title": "Shingeki no Kyojin: The Final Season Specials"}, {"type": "Synonym", "title": "Shingeki no Kyojin Season 4 Specials"}, {"type": "Synonym", "title": "Attack on Titan Season 4 Specials"}, {"type": "Synonym", "title": "Attack on Titan: The Final Season Specials"}, {"type": "Japanese", "title": "\u9032\u6483\u306e\u5de8\u4eba The Final Season Specials"}, {"type": "English", "title": "Attack on Titan: Chibi Theater - Survey Corps, The Final!"}], "title": "Shingeki no Kyojin: The Final Season Specials", "title_english": "Attack on Titan: Chibi Theater - Survey Corps, The Final!", "title_japanese": "\u9032\u6483\u306e\u5de8\u4eba The Final Season Specials", "title_synonyms": ["Shingeki no Kyojin Season 4 Specials", "Attack on Titan Season 4 Specials", "Attack on Titan: The Final Season Specials"], "type": "Special", "source": "Manga", "episodes": 2, "status": "Finished Airing", "airing": false, "aired": {"from": "2021-07-07T00:00:00+00:00", "to": "2021-08-04T00:00:00+00:00", "prop": {"from": {"day": 7, "month": 7, "year": 2021}, "to": {"day": 4, "month": 8, "year": 2021}}, "string": "Jul 7, 2021 to Aug 4, 2021"}, "duration": "5 min per ep", "rating": "G - All Ages", "score": 7.69, "scored_by": 6911, "rank": 1399, "popularity": 5463, "members": 19304, "favorites": 109, "synopsis": "Shingeki no Kyojin Season 4 DVD/BD specials. They are chibi parody shorts; one video was included on each of the DVD/BDs. Each video contained eight stories (essentially chibi parody short episode 60\u201375) which continued the chibi parody story from the previous chibi parody specials included on the DVD/BDs of the previous seasons.", "background": "", "season": null, "year": null, "broadcast": {"day": null, "time": null, "timezone": null, "string": null}, "producers": [], "licensors": [], "studios": [{"mal_id": 1155, "type": "anime", "name": "Studio Moriken", "url": "https://myanimelist.net/anime/producer/1155/Studio_Moriken"}], "genres": [{"mal_id": 4, "type": "anime", "name": "Comedy", "url": "https://myanimelist.net/anime/genre/4/Comedy"}], "explicit_genres": [], "themes": [{"mal_id": 20, "type": "anime", "name": "Parody", "url": "https://myanimelist.net/anime/genre/20/Parody"}], "demographics": []}}, "_timestamp": 1767038810.304767, "_is_permanent": true}

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View File

@@ -0,0 +1 @@
{"payload": {"data": [{"relation": "Prequel", "entry": [{"mal_id": 40748, "type": "anime", "name": "Jujutsu Kaisen", "url": "https://myanimelist.net/anime/40748/Jujutsu_Kaisen"}]}, {"relation": "Sequel", "entry": [{"mal_id": 57658, "type": "anime", "name": "Jujutsu Kaisen: Shimetsu Kaiyuu - Zenpen", "url": "https://myanimelist.net/anime/57658/Jujutsu_Kaisen__Shimetsu_Kaiyuu_-_Zenpen"}]}, {"relation": "Adaptation", "entry": [{"mal_id": 113138, "type": "manga", "name": "Jujutsu Kaisen", "url": "https://myanimelist.net/manga/113138/Jujutsu_Kaisen"}]}, {"relation": "Summary", "entry": [{"mal_id": 56243, "type": "anime", "name": "Jujutsu Kaisen 2nd Season Recaps", "url": "https://myanimelist.net/anime/56243/Jujutsu_Kaisen_2nd_Season_Recaps"}, {"mal_id": 59654, "type": "anime", "name": "Jujutsu Kaisen: Kaigyoku/Gyokusetsu", "url": "https://myanimelist.net/anime/59654/Jujutsu_Kaisen__Kaigyoku_Gyokusetsu"}, {"mal_id": 62392, "type": "anime", "name": "Jujutsu Kaisen Movie: Shibuya Jihen Tokubetsu Henshuu-ban x Shimetsu Kaiyuu Senkou Jouei", "url": "https://myanimelist.net/anime/62392/Jujutsu_Kaisen_Movie__Shibuya_Jihen_Tokubetsu_Henshuu-ban_x_Shimetsu_Kaiyuu_Senkou_Jouei"}]}]}, "_timestamp": 1767246612.990678, "_is_permanent": false}

View File

@@ -0,0 +1 @@
{"payload": {"data": [{"relation": "Sequel", "entry": [{"mal_id": 58567, "type": "anime", "name": "Ore dake Level Up na Ken Season 2: Arise from the Shadow", "url": "https://myanimelist.net/anime/58567/Ore_dake_Level_Up_na_Ken_Season_2__Arise_from_the_Shadow"}]}, {"relation": "Adaptation", "entry": [{"mal_id": 121496, "type": "manga", "name": "Solo Leveling", "url": "https://myanimelist.net/manga/121496/Solo_Leveling"}]}, {"relation": "Summary", "entry": [{"mal_id": 58224, "type": "anime", "name": "Ore dake Level Up na Ken: How to Get Stronger", "url": "https://myanimelist.net/anime/58224/Ore_dake_Level_Up_na_Ken__How_to_Get_Stronger"}, {"mal_id": 59841, "type": "anime", "name": "Ore dake Level Up na Ken: ReAwakening", "url": "https://myanimelist.net/anime/59841/Ore_dake_Level_Up_na_Ken__ReAwakening"}]}, {"relation": "Other", "entry": [{"mal_id": 51321, "type": "anime", "name": "Echo", "url": "https://myanimelist.net/anime/51321/Echo"}]}]}, "_timestamp": 1766977262.339336, "_is_permanent": false}

File diff suppressed because one or more lines are too long

View File

@@ -0,0 +1 @@
{"payload": {"data": {"mal_id": 58224, "url": "https://myanimelist.net/anime/58224/Ore_dake_Level_Up_na_Ken__How_to_Get_Stronger", "images": {"jpg": {"image_url": "https://cdn.myanimelist.net/images/anime/1043/141605.jpg", "small_image_url": "https://cdn.myanimelist.net/images/anime/1043/141605t.jpg", "large_image_url": "https://cdn.myanimelist.net/images/anime/1043/141605l.jpg"}, "webp": {"image_url": "https://cdn.myanimelist.net/images/anime/1043/141605.webp", "small_image_url": "https://cdn.myanimelist.net/images/anime/1043/141605t.webp", "large_image_url": "https://cdn.myanimelist.net/images/anime/1043/141605l.webp"}}, "trailer": {"youtube_id": null, "url": null, "embed_url": null, "images": {"image_url": null, "small_image_url": null, "medium_image_url": null, "large_image_url": null, "maximum_image_url": null}}, "approved": true, "titles": [{"type": "Default", "title": "Ore dake Level Up na Ken: How to Get Stronger"}, {"type": "Synonym", "title": "Solo Leveling Recap"}, {"type": "Synonym", "title": "Ore dake Level Up na Ken Recap"}, {"type": "Synonym", "title": "Solo Leveling Episode 7.5"}, {"type": "Synonym", "title": "Ore dake Level Up na Ken Episode 7.5"}, {"type": "Synonym", "title": "\ub098 \ud63c\uc790\ub9cc \ub808\ubca8\uc5c5"}, {"type": "Japanese", "title": "\u4ffa\u3060\u3051\u30ec\u30d9\u30eb\u30a2\u30c3\u30d7\u306a\u4ef6 How to Get Stronger"}, {"type": "English", "title": "Solo Leveling: How to Get Stronger"}], "title": "Ore dake Level Up na Ken: How to Get Stronger", "title_english": "Solo Leveling: How to Get Stronger", "title_japanese": "\u4ffa\u3060\u3051\u30ec\u30d9\u30eb\u30a2\u30c3\u30d7\u306a\u4ef6 How to Get Stronger", "title_synonyms": ["Solo Leveling Recap", "Ore dake Level Up na Ken Recap", "Solo Leveling Episode 7.5", "Ore dake Level Up na Ken Episode 7.5", "\ub098 \ud63c\uc790\ub9cc \ub808\ubca8\uc5c5"], "type": "TV Special", "source": "Web manga", "episodes": 1, "status": "Finished Airing", "airing": false, "aired": {"from": "2024-02-25T00:00:00+00:00", "to": null, "prop": {"from": {"day": 25, "month": 2, "year": 2024}, "to": {"day": null, "month": null, "year": null}}, "string": "Feb 25, 2024"}, "duration": "23 min", "rating": "R - 17+ (violence & profanity)", "score": 6.33, "scored_by": 13033, "rank": 8422, "popularity": 4670, "members": 28237, "favorites": 152, "synopsis": "Recap of the first seven episodes of Ore dake Level Up na Ken.", "background": "", "season": null, "year": null, "broadcast": {"day": null, "time": null, "timezone": null, "string": null}, "producers": [], "licensors": [{"mal_id": 1468, "type": "anime", "name": "Crunchyroll", "url": "https://myanimelist.net/anime/producer/1468/Crunchyroll"}], "studios": [{"mal_id": 56, "type": "anime", "name": "A-1 Pictures", "url": "https://myanimelist.net/anime/producer/56/A-1_Pictures"}], "genres": [{"mal_id": 1, "type": "anime", "name": "Action", "url": "https://myanimelist.net/anime/genre/1/Action"}, {"mal_id": 2, "type": "anime", "name": "Adventure", "url": "https://myanimelist.net/anime/genre/2/Adventure"}, {"mal_id": 10, "type": "anime", "name": "Fantasy", "url": "https://myanimelist.net/anime/genre/10/Fantasy"}], "explicit_genres": [], "themes": [{"mal_id": 50, "type": "anime", "name": "Adult Cast", "url": "https://myanimelist.net/anime/genre/50/Adult_Cast"}, {"mal_id": 82, "type": "anime", "name": "Urban Fantasy", "url": "https://myanimelist.net/anime/genre/82/Urban_Fantasy"}], "demographics": []}}, "_timestamp": 1766977265.8795462, "_is_permanent": true}

View File

@@ -0,0 +1 @@
{"payload": {"data": {"mal_id": 62392, "url": "https://myanimelist.net/anime/62392/Jujutsu_Kaisen_Movie__Shibuya_Jihen_Tokubetsu_Henshuu-ban_x_Shimetsu_Kaiyuu_Senkou_Jouei", "images": {"jpg": {"image_url": "https://cdn.myanimelist.net/images/anime/1438/154138.jpg", "small_image_url": "https://cdn.myanimelist.net/images/anime/1438/154138t.jpg", "large_image_url": "https://cdn.myanimelist.net/images/anime/1438/154138l.jpg"}, "webp": {"image_url": "https://cdn.myanimelist.net/images/anime/1438/154138.webp", "small_image_url": "https://cdn.myanimelist.net/images/anime/1438/154138t.webp", "large_image_url": "https://cdn.myanimelist.net/images/anime/1438/154138l.webp"}}, "trailer": {"youtube_id": null, "url": null, "embed_url": "https://www.youtube-nocookie.com/embed/VEjVqXiiUNY?enablejsapi=1&wmode=opaque&autoplay=1", "images": {"image_url": null, "small_image_url": null, "medium_image_url": null, "large_image_url": null, "maximum_image_url": null}}, "approved": true, "titles": [{"type": "Default", "title": "Jujutsu Kaisen Movie: Shibuya Jihen Tokubetsu Henshuu-ban x Shimetsu Kaiyuu Senkou Jouei"}, {"type": "Japanese", "title": "\u5287\u5834\u7248 \u546a\u8853\u5efb\u6226\u300e\u6e0b\u8c37\u4e8b\u5909 \u7279\u5225\u7de8\u96c6\u7248\u300f\u00d7\u300e\u6b7b\u6ec5\u56de\u6e38 \u5148\u884c\u4e0a\u6620\u300f"}, {"type": "English", "title": "Jujutsu Kaisen: Execution"}], "title": "Jujutsu Kaisen Movie: Shibuya Jihen Tokubetsu Henshuu-ban x Shimetsu Kaiyuu Senkou Jouei", "title_english": "Jujutsu Kaisen: Execution", "title_japanese": "\u5287\u5834\u7248 \u546a\u8853\u5efb\u6226\u300e\u6e0b\u8c37\u4e8b\u5909 \u7279\u5225\u7de8\u96c6\u7248\u300f\u00d7\u300e\u6b7b\u6ec5\u56de\u6e38 \u5148\u884c\u4e0a\u6620\u300f", "title_synonyms": [], "type": "Movie", "source": "Manga", "episodes": 1, "status": "Finished Airing", "airing": false, "aired": {"from": "2025-11-07T00:00:00+00:00", "to": null, "prop": {"from": {"day": 7, "month": 11, "year": 2025}, "to": {"day": null, "month": null, "year": null}}, "string": "Nov 7, 2025"}, "duration": "1 hr 28 min", "rating": "R - 17+ (violence & profanity)", "score": 7.23, "scored_by": 9361, "rank": 3346, "popularity": 4916, "members": 25006, "favorites": 44, "synopsis": "Compilation of the Shibuya Incident from the second season and the first two episodes of Jujutsu Kaisen: Shimetsu Kaiyuu - Zenpen.", "background": "", "season": null, "year": null, "broadcast": {"day": null, "time": null, "timezone": null, "string": null}, "producers": [{"mal_id": 1856, "type": "anime", "name": "dugout", "url": "https://myanimelist.net/anime/producer/1856/dugout"}], "licensors": [{"mal_id": 783, "type": "anime", "name": "GKIDS", "url": "https://myanimelist.net/anime/producer/783/GKIDS"}], "studios": [{"mal_id": 569, "type": "anime", "name": "MAPPA", "url": "https://myanimelist.net/anime/producer/569/MAPPA"}], "genres": [{"mal_id": 1, "type": "anime", "name": "Action", "url": "https://myanimelist.net/anime/genre/1/Action"}, {"mal_id": 37, "type": "anime", "name": "Supernatural", "url": "https://myanimelist.net/anime/genre/37/Supernatural"}], "explicit_genres": [], "themes": [{"mal_id": 58, "type": "anime", "name": "Gore", "url": "https://myanimelist.net/anime/genre/58/Gore"}, {"mal_id": 23, "type": "anime", "name": "School", "url": "https://myanimelist.net/anime/genre/23/School"}], "demographics": [{"mal_id": 27, "type": "anime", "name": "Shounen", "url": "https://myanimelist.net/anime/genre/27/Shounen"}]}}, "_timestamp": 1766968779.694751, "_is_permanent": true}

View File

@@ -0,0 +1 @@
{"payload": {"data": {"mal_id": 59978, "url": "https://myanimelist.net/anime/59978/Sousou_no_Frieren_2nd_Season", "images": {"jpg": {"image_url": "https://cdn.myanimelist.net/images/anime/1921/154528.jpg", "small_image_url": "https://cdn.myanimelist.net/images/anime/1921/154528t.jpg", "large_image_url": "https://cdn.myanimelist.net/images/anime/1921/154528l.jpg"}, "webp": {"image_url": "https://cdn.myanimelist.net/images/anime/1921/154528.webp", "small_image_url": "https://cdn.myanimelist.net/images/anime/1921/154528t.webp", "large_image_url": "https://cdn.myanimelist.net/images/anime/1921/154528l.webp"}}, "trailer": {"youtube_id": null, "url": null, "embed_url": "https://www.youtube-nocookie.com/embed/MwP4gqRys4c?enablejsapi=1&wmode=opaque&autoplay=1", "images": {"image_url": null, "small_image_url": null, "medium_image_url": null, "large_image_url": null, "maximum_image_url": null}}, "approved": true, "titles": [{"type": "Default", "title": "Sousou no Frieren 2nd Season"}, {"type": "Synonym", "title": "Frieren at the Funeral Season 2"}, {"type": "Japanese", "title": "\u846c\u9001\u306e\u30d5\u30ea\u30fc\u30ec\u30f3 \u7b2c2\u671f"}, {"type": "English", "title": "Frieren: Beyond Journey's End Season 2"}], "title": "Sousou no Frieren 2nd Season", "title_english": "Frieren: Beyond Journey's End Season 2", "title_japanese": "\u846c\u9001\u306e\u30d5\u30ea\u30fc\u30ec\u30f3 \u7b2c2\u671f", "title_synonyms": ["Frieren at the Funeral Season 2"], "type": "TV", "source": "Manga", "episodes": null, "status": "Not yet aired", "airing": false, "aired": {"from": "2026-01-16T00:00:00+00:00", "to": null, "prop": {"from": {"day": 16, "month": 1, "year": 2026}, "to": {"day": null, "month": null, "year": null}}, "string": "Jan 16, 2026 to ?"}, "duration": "Unknown", "rating": "PG-13 - Teens 13 or older", "score": null, "scored_by": null, "rank": null, "popularity": 1068, "members": 255898, "favorites": 2555, "synopsis": "Second season of Sousou no Frieren.", "background": "Sousou no Frieren 2nd Season aired on Nippon TV's Friday Anime Night block.", "season": "winter", "year": 2026, "broadcast": {"day": "Fridays", "time": "23:00", "timezone": "Asia/Tokyo", "string": "Fridays at 23:00 (JST)"}, "producers": [{"mal_id": 1143, "type": "anime", "name": "TOHO animation", "url": "https://myanimelist.net/anime/producer/1143/TOHO_animation"}], "licensors": [], "studios": [{"mal_id": 11, "type": "anime", "name": "Madhouse", "url": "https://myanimelist.net/anime/producer/11/Madhouse"}], "genres": [{"mal_id": 2, "type": "anime", "name": "Adventure", "url": "https://myanimelist.net/anime/genre/2/Adventure"}, {"mal_id": 8, "type": "anime", "name": "Drama", "url": "https://myanimelist.net/anime/genre/8/Drama"}, {"mal_id": 10, "type": "anime", "name": "Fantasy", "url": "https://myanimelist.net/anime/genre/10/Fantasy"}], "explicit_genres": [], "themes": [], "demographics": [{"mal_id": 27, "type": "anime", "name": "Shounen", "url": "https://myanimelist.net/anime/genre/27/Shounen"}]}}, "_timestamp": 1766978993.2388558, "_is_permanent": false}

View File

@@ -0,0 +1 @@
{"payload": {"data": [{"relation": "Sequel", "entry": [{"mal_id": 59978, "type": "anime", "name": "Sousou no Frieren 2nd Season", "url": "https://myanimelist.net/anime/59978/Sousou_no_Frieren_2nd_Season"}]}, {"relation": "Adaptation", "entry": [{"mal_id": 126287, "type": "manga", "name": "Sousou no Frieren", "url": "https://myanimelist.net/manga/126287/Sousou_no_Frieren"}]}, {"relation": "Side Story", "entry": [{"mal_id": 56885, "type": "anime", "name": "Sousou no Frieren: \u25cf\u25cf no Mahou", "url": "https://myanimelist.net/anime/56885/Sousou_no_Frieren__\u25cf\u25cf_no_Mahou"}]}, {"relation": "Other", "entry": [{"mal_id": 56805, "type": "anime", "name": "Yuusha", "url": "https://myanimelist.net/anime/56805/Yuusha"}, {"mal_id": 58313, "type": "anime", "name": "Haru (2024)", "url": "https://myanimelist.net/anime/58313/Haru_2024"}]}]}, "_timestamp": 1766978992.224694, "_is_permanent": false}

View File

@@ -0,0 +1 @@
{"payload": {"data": {"mal_id": 59841, "url": "https://myanimelist.net/anime/59841/Ore_dake_Level_Up_na_Ken__ReAwakening", "images": {"jpg": {"image_url": "https://cdn.myanimelist.net/images/anime/1983/146190.jpg", "small_image_url": "https://cdn.myanimelist.net/images/anime/1983/146190t.jpg", "large_image_url": "https://cdn.myanimelist.net/images/anime/1983/146190l.jpg"}, "webp": {"image_url": "https://cdn.myanimelist.net/images/anime/1983/146190.webp", "small_image_url": "https://cdn.myanimelist.net/images/anime/1983/146190t.webp", "large_image_url": "https://cdn.myanimelist.net/images/anime/1983/146190l.webp"}}, "trailer": {"youtube_id": null, "url": null, "embed_url": "https://www.youtube-nocookie.com/embed/AwaHrHMgz5Q?enablejsapi=1&wmode=opaque&autoplay=1", "images": {"image_url": null, "small_image_url": null, "medium_image_url": null, "large_image_url": null, "maximum_image_url": null}}, "approved": true, "titles": [{"type": "Default", "title": "Ore dake Level Up na Ken: ReAwakening"}, {"type": "Japanese", "title": "\u4ffa\u3060\u3051\u30ec\u30d9\u30eb\u30a2\u30c3\u30d7\u306a\u4ef6 -ReAwakening-"}, {"type": "English", "title": "Solo Leveling: ReAwakening"}], "title": "Ore dake Level Up na Ken: ReAwakening", "title_english": "Solo Leveling: ReAwakening", "title_japanese": "\u4ffa\u3060\u3051\u30ec\u30d9\u30eb\u30a2\u30c3\u30d7\u306a\u4ef6 -ReAwakening-", "title_synonyms": [], "type": "Movie", "source": "Web manga", "episodes": 1, "status": "Finished Airing", "airing": false, "aired": {"from": "2024-11-29T00:00:00+00:00", "to": null, "prop": {"from": {"day": 29, "month": 11, "year": 2024}, "to": {"day": null, "month": null, "year": null}}, "string": "Nov 29, 2024"}, "duration": "1 hr 56 min", "rating": "R - 17+ (violence & profanity)", "score": 8.09, "scored_by": 10987, "rank": 570, "popularity": 3553, "members": 48575, "favorites": 220, "synopsis": "Ever since dangerous gates leading to dungeons filled with monsters appeared on Earth a decade ago, humans gained a new way to provide for themselves: certain individuals designated as hunters received otherworldly powers to fight the dangers within. Sung Jin-Woo works desperately as a hunter to keep his family afloat. However, due to his lack of power, he is known as the weakest hunter of all mankind.\n\nDuring a dungeon expedition with his party, an unexpected disaster occurs, leaving Jin-Woo on the brink of death. Inexplicably surviving the grave situation, he wakes up in a hospital after the incident with a surprise\u2014he has been selected as a player by a mysterious system, offering him possibilities to improve his skills. As he is seemingly the only one who has been chosen, Jin-Woo begins to level up at a fast pace, and he might just be able to become the world's strongest hunter with his reawakening.\n\n[Written by MAL Rewrite]", "background": "Ore dake Level Up na Ken: ReAwakening premiered in North America in theaters as Solo Leveling: ReAwakening on December 6, 2024.", "season": null, "year": null, "broadcast": {"day": null, "time": null, "timezone": null, "string": null}, "producers": [{"mal_id": 17, "type": "anime", "name": "Aniplex", "url": "https://myanimelist.net/anime/producer/17/Aniplex"}], "licensors": [], "studios": [{"mal_id": 56, "type": "anime", "name": "A-1 Pictures", "url": "https://myanimelist.net/anime/producer/56/A-1_Pictures"}], "genres": [{"mal_id": 1, "type": "anime", "name": "Action", "url": "https://myanimelist.net/anime/genre/1/Action"}, {"mal_id": 2, "type": "anime", "name": "Adventure", "url": "https://myanimelist.net/anime/genre/2/Adventure"}, {"mal_id": 10, "type": "anime", "name": "Fantasy", "url": "https://myanimelist.net/anime/genre/10/Fantasy"}], "explicit_genres": [], "themes": [{"mal_id": 50, "type": "anime", "name": "Adult Cast", "url": "https://myanimelist.net/anime/genre/50/Adult_Cast"}, {"mal_id": 82, "type": "anime", "name": "Urban Fantasy", "url": "https://myanimelist.net/anime/genre/82/Urban_Fantasy"}], "demographics": []}}, "_timestamp": 1766977263.451645, "_is_permanent": true}

File diff suppressed because one or more lines are too long

View File

@@ -0,0 +1 @@
{"payload": {"data": {"mal_id": 56885, "url": "https://myanimelist.net/anime/56885/Sousou_no_Frieren__\u25cf\u25cf_no_Mahou", "images": {"jpg": {"image_url": "https://cdn.myanimelist.net/images/anime/1045/150038.jpg", "small_image_url": "https://cdn.myanimelist.net/images/anime/1045/150038t.jpg", "large_image_url": "https://cdn.myanimelist.net/images/anime/1045/150038l.jpg"}, "webp": {"image_url": "https://cdn.myanimelist.net/images/anime/1045/150038.webp", "small_image_url": "https://cdn.myanimelist.net/images/anime/1045/150038t.webp", "large_image_url": "https://cdn.myanimelist.net/images/anime/1045/150038l.webp"}}, "trailer": {"youtube_id": null, "url": null, "embed_url": null, "images": {"image_url": null, "small_image_url": null, "medium_image_url": null, "large_image_url": null, "maximum_image_url": null}}, "approved": true, "titles": [{"type": "Default", "title": "Sousou no Frieren: \u25cf\u25cf no Mahou"}, {"type": "Synonym", "title": "Sousou no Frieren: Marumaru no Mahou"}, {"type": "Japanese", "title": "\u846c\u9001\u306e\u30d5\u30ea\u30fc\u30ec\u30f3 \uff5e\u25cf\u25cf\u306e\u9b54\u6cd5\uff5e"}, {"type": "English", "title": "Frieren: Beyond Journey's End Mini Anime"}], "title": "Sousou no Frieren: \u25cf\u25cf no Mahou", "title_english": "Frieren: Beyond Journey's End Mini Anime", "title_japanese": "\u846c\u9001\u306e\u30d5\u30ea\u30fc\u30ec\u30f3 \uff5e\u25cf\u25cf\u306e\u9b54\u6cd5\uff5e", "title_synonyms": ["Sousou no Frieren: Marumaru no Mahou"], "type": "ONA", "source": "Manga", "episodes": 18, "status": "Finished Airing", "airing": false, "aired": {"from": "2023-10-11T00:00:00+00:00", "to": "2025-09-03T00:00:00+00:00", "prop": {"from": {"day": 11, "month": 10, "year": 2023}, "to": {"day": 3, "month": 9, "year": 2025}}, "string": "Oct 11, 2023 to Sep 3, 2025"}, "duration": "1 min per ep", "rating": "PG-13 - Teens 13 or older", "score": 7.37, "scored_by": 9081, "rank": 2579, "popularity": 5175, "members": 22134, "favorites": 70, "synopsis": "Frieren loves collecting peculiar spells, and her ever-growing arsenal of magic contains the perfect spells for many occasions. Whether it be by stealthily removing alcohol from Heiter's drinks or by remedying her own sleep issues, Frieren is sure to brighten her companions' days with her magic.\n\n[Written by MAL Rewrite]", "background": "This entry includes the Mizkan Ajipon collaboration episode.", "season": null, "year": null, "broadcast": {"day": null, "time": null, "timezone": null, "string": null}, "producers": [{"mal_id": 1294, "type": "anime", "name": "Sound Team Don Juan", "url": "https://myanimelist.net/anime/producer/1294/Sound_Team_Don_Juan"}], "licensors": [], "studios": [{"mal_id": 2705, "type": "anime", "name": "TOHO animation STUDIO", "url": "https://myanimelist.net/anime/producer/2705/TOHO_animation_STUDIO"}], "genres": [{"mal_id": 4, "type": "anime", "name": "Comedy", "url": "https://myanimelist.net/anime/genre/4/Comedy"}, {"mal_id": 10, "type": "anime", "name": "Fantasy", "url": "https://myanimelist.net/anime/genre/10/Fantasy"}], "explicit_genres": [], "themes": [], "demographics": []}}, "_timestamp": 1766978993.488814, "_is_permanent": true}

View File

@@ -0,0 +1 @@
{"payload": {"data": [{"relation": "Prequel", "entry": [{"mal_id": 38524, "type": "anime", "name": "Shingeki no Kyojin Season 3 Part 2", "url": "https://myanimelist.net/anime/38524/Shingeki_no_Kyojin_Season_3_Part_2"}]}, {"relation": "Sequel", "entry": [{"mal_id": 48583, "type": "anime", "name": "Shingeki no Kyojin: The Final Season Part 2", "url": "https://myanimelist.net/anime/48583/Shingeki_no_Kyojin__The_Final_Season_Part_2"}]}, {"relation": "Adaptation", "entry": [{"mal_id": 23390, "type": "manga", "name": "Shingeki no Kyojin", "url": "https://myanimelist.net/manga/23390/Shingeki_no_Kyojin"}]}, {"relation": "Side Story", "entry": [{"mal_id": 49627, "type": "anime", "name": "Shingeki no Kyojin: The Final Season Specials", "url": "https://myanimelist.net/anime/49627/Shingeki_no_Kyojin__The_Final_Season_Specials"}]}, {"relation": "Other", "entry": [{"mal_id": 51618, "type": "anime", "name": "Boku no Sensou", "url": "https://myanimelist.net/anime/51618/Boku_no_Sensou"}]}]}, "_timestamp": 1767038809.293995, "_is_permanent": false}

View File

@@ -0,0 +1 @@
{"payload": {"data": {"mal_id": 59571, "url": "https://myanimelist.net/anime/59571/Shingeki_no_Kyojin_Movie__Kanketsu-hen_-_The_Last_Attack", "images": {"jpg": {"image_url": "https://cdn.myanimelist.net/images/anime/1379/145452.jpg", "small_image_url": "https://cdn.myanimelist.net/images/anime/1379/145452t.jpg", "large_image_url": "https://cdn.myanimelist.net/images/anime/1379/145452l.jpg"}, "webp": {"image_url": "https://cdn.myanimelist.net/images/anime/1379/145452.webp", "small_image_url": "https://cdn.myanimelist.net/images/anime/1379/145452t.webp", "large_image_url": "https://cdn.myanimelist.net/images/anime/1379/145452l.webp"}}, "trailer": {"youtube_id": null, "url": null, "embed_url": "https://www.youtube-nocookie.com/embed/PuE1uRbpkTk?enablejsapi=1&wmode=opaque&autoplay=1", "images": {"image_url": null, "small_image_url": null, "medium_image_url": null, "large_image_url": null, "maximum_image_url": null}}, "approved": true, "titles": [{"type": "Default", "title": "Shingeki no Kyojin Movie: Kanketsu-hen - The Last Attack"}, {"type": "Synonym", "title": "Attack on Titan the Movie: The Last Attack"}, {"type": "Japanese", "title": "\u5287\u5834\u7248 \u9032\u6483\u306e\u5de8\u4eba \u5b8c\u7d50\u7de8 THE LAST ATTACK"}, {"type": "English", "title": "Attack on Titan: The Last Attack"}], "title": "Shingeki no Kyojin Movie: Kanketsu-hen - The Last Attack", "title_english": "Attack on Titan: The Last Attack", "title_japanese": "\u5287\u5834\u7248 \u9032\u6483\u306e\u5de8\u4eba \u5b8c\u7d50\u7de8 THE LAST ATTACK", "title_synonyms": ["Attack on Titan the Movie: The Last Attack"], "type": "Movie", "source": "Manga", "episodes": 1, "status": "Finished Airing", "airing": false, "aired": {"from": "2024-11-08T00:00:00+00:00", "to": null, "prop": {"from": {"day": 8, "month": 11, "year": 2024}, "to": {"day": null, "month": null, "year": null}}, "string": "Nov 8, 2024"}, "duration": "2 hr 24 min", "rating": "R - 17+ (violence & profanity)", "score": 8.83, "scored_by": 30653, "rank": 33, "popularity": 2862, "members": 71909, "favorites": 526, "synopsis": "A compilation movie for Shingeki no Kyojin: The Final Season - Kanketsu-hen.", "background": "", "season": null, "year": null, "broadcast": {"day": null, "time": null, "timezone": null, "string": null}, "producers": [{"mal_id": 144, "type": "anime", "name": "Pony Canyon", "url": "https://myanimelist.net/anime/producer/144/Pony_Canyon"}], "licensors": [], "studios": [{"mal_id": 569, "type": "anime", "name": "MAPPA", "url": "https://myanimelist.net/anime/producer/569/MAPPA"}], "genres": [{"mal_id": 1, "type": "anime", "name": "Action", "url": "https://myanimelist.net/anime/genre/1/Action"}, {"mal_id": 8, "type": "anime", "name": "Drama", "url": "https://myanimelist.net/anime/genre/8/Drama"}, {"mal_id": 41, "type": "anime", "name": "Suspense", "url": "https://myanimelist.net/anime/genre/41/Suspense"}], "explicit_genres": [], "themes": [{"mal_id": 58, "type": "anime", "name": "Gore", "url": "https://myanimelist.net/anime/genre/58/Gore"}, {"mal_id": 38, "type": "anime", "name": "Military", "url": "https://myanimelist.net/anime/genre/38/Military"}, {"mal_id": 76, "type": "anime", "name": "Survival", "url": "https://myanimelist.net/anime/genre/76/Survival"}], "demographics": [{"mal_id": 27, "type": "anime", "name": "Shounen", "url": "https://myanimelist.net/anime/genre/27/Shounen"}]}}, "_timestamp": 1767039257.8311648, "_is_permanent": true}

View File

@@ -0,0 +1 @@
{"payload": {"data": [{"relation": "Adaptation", "entry": [{"mal_id": 23390, "type": "manga", "name": "Shingeki no Kyojin", "url": "https://myanimelist.net/manga/23390/Shingeki_no_Kyojin"}]}, {"relation": "Full Story", "entry": [{"mal_id": 51535, "type": "anime", "name": "Shingeki no Kyojin: The Final Season - Kanketsu-hen", "url": "https://myanimelist.net/anime/51535/Shingeki_no_Kyojin__The_Final_Season_-_Kanketsu-hen"}]}]}, "_timestamp": 1767039257.1753879, "_is_permanent": false}

File diff suppressed because one or more lines are too long

28
data/profiles.json Normal file
View File

@@ -0,0 +1,28 @@
[
{
"id": "3e86258a-b4f7-48a2-8bed-3ae6d89f400b",
"name": "Arkm",
"avatar_url": "/data/avatars/3e86258a-b4f7-48a2-8bed-3ae6d89f400b.jpeg",
"settings": {
"theme": "dark",
"pushNotifications": false,
"downloadQuality": "1080p",
"nsfw_enabled": true,
"module_preferences": {}
},
"watch_history": {
"52299": {
"title": "Solo Leveling",
"episodes": {
"5": {
"watched_at": "2025-12-28T23:35:22Z",
"season_number": 1,
"state": "finished",
"timestamp": 0
}
},
"last_watched": "2025-12-28T23:35:22Z"
}
}
}
]

8
logo.txt Normal file
View File

@@ -0,0 +1,8 @@
_____________ _____ ___ __ ___ ___ _______ ___ ___
|__ ___| (\" \|" \ |" \ |" \ /" | /" "||" \/" |
__/ /_\ \__ |.\\ \ | || | \ \ // |(: ______) \ \ /
|__ ___ __| |: \. \\ | |: | /\\ \/. | \/ | \\ \/
/ / _ \ \ |. \ \. | |. | |: \. | // ___)_ /\. \
/ / / \ \ \ | \ \ | /\ |\ |. \ /: |(: "| / \ \
/_/ |_| \_\ \___|\____\)(__\_|_)|___|\__/|___| \_______||___/\___|

144
modules/animekai.module Normal file
View File

@@ -0,0 +1,144 @@
{
"name": "AnimeKai Streamer",
"version": "1.4.0",
"author": "Animex",
"description": "Cloud-optimized AnimeKai resolver. Handles Tunnel routing and deep decryption.",
"type": ["ANIME_STREAMER"],
"requirements": ["httpx", "beautifulsoup4"]
}
---
import re
import json
import httpx
import urllib.parse
from typing import Optional, Dict, List, Any
# Constants
BASE_URL = "https://animekai.to"
ENC_API = "https://enc-dec.app/api"
DB_URL = "https://enc-dec.app/db/kai/find"
UA = "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36"
async def get_iframe_source(mal_id: int, episode: int, dub: bool) -> Optional[str]:
"""
Core resolver function. 'httpx' here is the HybridClient injected by app.py.
"""
try:
# 1. Resolve MAL ID to AnimeKai metadata
meta_res = await httpx.get(f"{DB_URL}?mal_id={mal_id}")
meta_res.raise_for_status()
meta_data = meta_res.json()
if not meta_data:
return None
# 2. Locate the specific episode token
lang_key = "2" if dub else "1"
ep_entry = meta_data[0].get("episodes", {}).get(lang_key, {}).get(str(episode))
if not ep_entry:
# Fallback to sub if dub is missing
ep_entry = meta_data[0].get("episodes", {}).get("1", {}).get(str(episode))
if not ep_entry:
return None
token = ep_entry["token"]
# 3. Get Server List (requires an encrypted version of the token)
enc_token_req = await httpx.get(f"{ENC_API}/enc-kai", params={"text": token})
enc_token = enc_token_req.json().get("result")
servers_res = await httpx.get(f"{BASE_URL}/ajax/links/list", params={"token": token, "_": enc_token})
servers_html = servers_res.json().get("result", "")
# Extract server data-lids
server_ids = re.findall(r'data-lid="([^"]+)"', servers_html)
if not server_ids:
return None
# 4. Iterate through servers to find a working stream
for sid in server_ids:
try:
# Encrypt SID for the view-link request
enc_sid_req = await httpx.get(f"{ENC_API}/enc-kai", params={"text": sid})
enc_sid = enc_sid_req.json().get("result")
view_res = await httpx.get(f"{BASE_URL}/ajax/links/view", params={"id": sid, "_": enc_sid})
# Decrypt the view response to get the embed host URL and skip times
dec_view_req = await httpx.post(f"{ENC_API}/dec-kai", json_data={"text": view_res.json().get("result")})
dec_view = dec_view_req.json().get("result")
if not dec_view or "url" not in dec_view:
continue
# Convert embed URL to media API URL (e.g., megaup.nl/e/... -> megaup.nl/media/...)
media_url = dec_view["url"].replace("/e/", "/media/")
# Fetch the media page (this is where the Home Agent's User-Agent is crucial)
media_page = await httpx.get(media_url, headers={"User-Agent": UA, "Referer": BASE_URL})
# 5. Robust Extraction of the encrypted "result" blob
# We use regex directly on the HTML to find the result string
blob_match = re.search(r'"result"\s*:\s*"([^"]+)"', media_page.text)
if not blob_match:
continue
encrypted_blob = blob_match.group(1)
# 6. Final decryption of the stream sources
final_dec_req = await httpx.post(f"{ENC_API}/dec-mega", json_data={"text": encrypted_blob, "agent": UA})
final_data = final_dec_req.json().get("result", {})
sources = final_data.get("sources", [])
if not sources:
continue
video_url = sources[0].get("file")
# 7. Construct the final player URL for the frontend
# Extract the host for the referer header
parsed_uri = urllib.parse.urlparse(media_url)
referer_host = f"{parsed_uri.scheme}://{parsed_uri.netloc}"
params = {
"video": video_url,
"referer": referer_host,
"id": mal_id,
"episode": episode,
"stream": "true",
"full": "true"
}
# Add skip times (Intro/Outro) if available
if dec_view.get("skip"):
params["skip_times"] = json.dumps(dec_view["skip"])
# Add subtitles if available
tracks = final_data.get("tracks", [])
subs = [t for t in tracks if t.get("kind") == "captions"]
if subs:
params["captions"] = json.dumps(subs)
return f"/video_player.html?{urllib.parse.urlencode(params)}"
except Exception as e:
print(f"[AnimeKai] Sid {sid} failed: {str(e)}")
continue
return None
except Exception as e:
print(f"[AnimeKai] Global Module Error: {str(e)}")
return None
async def get_download_link(mal_id: int, episode: int, dub: bool, quality: str) -> Optional[str]:
"""
Returns the direct m3u8 link.
"""
source_url = await get_iframe_source(mal_id, episode, dub)
if source_url:
parsed = urllib.parse.urlparse(source_url)
qs = urllib.parse.parse_qs(parsed.query)
return qs.get("video", [None])[0]
return None

184
modules/mangadex.module Normal file
View File

@@ -0,0 +1,184 @@
{
"name": "MangaDex Reader",
"version": "1.0.0",
"author": "Animex",
"description": "Fetches manga chapters and page images from MangaDex using their v5 API.",
"type": "MANGA_READER",
"requirements": ["httpx"]
}
---
import asyncio
import httpx
from typing import Optional, List, Dict, Any
# --- Helper Functions ---
def _uses_hybrid_client() -> bool:
return not hasattr(httpx, "AsyncClient")
async def _fetch_json(url: str, params: Dict[str, Any] = None, headers: Dict[str, str] = None, timeout: int = 10) -> Dict[str, Any]:
if _uses_hybrid_client():
resp = await httpx.get(url, params=params, headers=headers, timeout=timeout)
else:
async with httpx.AsyncClient() as client:
resp = await client.get(url, params=params, headers=headers, timeout=timeout)
resp.raise_for_status()
return resp.json()
async def get_title_from_mal(mal_id: int, client: httpx.AsyncClient) -> Optional[str]:
"""
Fetches the primary English or Romaji title from Jikan (MAL API)
to use for searching MangaDex.
"""
url = f"https://api.jikan.moe/v4/manga/{mal_id}"
try:
data = await _fetch_json(url)
# Prefer English title for search accuracy, fallback to default title
return data.get("data", {}).get("title_english") or data.get("data", {}).get("title")
except Exception as e:
print(f"MangaDex-Module: Jikan API error: {e}")
return None
async def find_mangadex_id(mal_id: int, title: str, client: httpx.AsyncClient) -> Optional[str]:
"""
Searches MangaDex for the title and verifies the MAL ID in the metadata
to ensure we have the correct manga.
"""
search_url = "https://api.mangadex.org/manga"
params = {
"title": title,
"limit": 10,
"order[relevance]": "desc"
}
try:
data = await _fetch_json(search_url, params=params)
results = data.get("data", [])
for manga in results:
attributes = manga.get("attributes", {})
links = attributes.get("links", {})
# Check if the MAL ID provided in MangaDex metadata matches our target
# Note: links['mal'] is a string in their API
if links.get("mal") == str(mal_id):
return manga["id"]
# Fallback: If no strict MAL ID match found, return the first result
# if the titles are very similar (basic loose match)
if results:
print(f"MangaDex-Module: Strict MAL ID match failed. Defaulting to top search result: {results[0]['attributes']['title']}")
return results[0]["id"]
return None
except Exception as e:
print(f"MangaDex-Module: Search failed: {e}")
return None
# --- Main Module Functions ---
async def get_chapters(mal_id: int) -> Optional[List[Dict[str, Any]]]:
"""
Asynchronously gets a list of chapters for a given MyAnimeList ID
via MangaDex API.
"""
title = await get_title_from_mal(mal_id, httpx)
if not title:
print("MangaDex-Module: Could not retrieve title from MAL.")
return None
md_id = await find_mangadex_id(mal_id, title, httpx)
if not md_id:
print(f"MangaDex-Module: Could not find MangaDex ID for MAL ID {mal_id}")
return None
feed_url = f"https://api.mangadex.org/manga/{md_id}/feed"
params = {
"translatedLanguage[]": "en",
"order[chapter]": "desc",
"limit": 500,
"includes[]": "scanlation_group"
}
try:
data = await _fetch_json(feed_url, params=params)
chapters = data.get("data", [])
formatted_chapters = []
seen_chapters = set()
for ch in chapters:
attr = ch.get("attributes", {})
chapter_num = attr.get("chapter")
if chapter_num is None:
continue
if chapter_num in seen_chapters:
continue
seen_chapters.add(chapter_num)
chapter_title = attr.get("title") or f"Chapter {chapter_num}"
formatted_chapters.append({
"title": chapter_title,
"url": ch["id"],
"chapter_number": str(chapter_num)
})
return formatted_chapters
except Exception as e:
print(f"MangaDex-Module: Error fetching chapters: {e}")
return None
async def get_chapter_images(mal_id: int, chapter_num: str) -> Optional[List[str]]:
"""
Asynchronously gets page image URLs for a specific chapter number.
Note: 'chapter_num' is used to look up the UUID from the chapter list logic.
"""
# 1. We need the Chapter UUID. Re-using get_chapters to map Num -> UUID.
# In a production app, you might cache the chapter list to avoid this extra call.
all_chapters = await get_chapters(mal_id)
if not all_chapters:
return None
chapter_uuid = None
for ch in all_chapters:
if ch.get("chapter_number") == str(chapter_num):
chapter_uuid = ch.get("url") # This contains the UUID from get_chapters
break
if not chapter_uuid:
print(f"MangaDex-Module: Chapter {chapter_num} not found for MAL ID {mal_id}")
return None
# 2. Call MangaDex At-Home API to get image metadata
async with httpx.AsyncClient() as client:
try:
at_home_url = f"https://api.mangadex.org/at-home/server/{chapter_uuid}"
resp = await client.get(at_home_url, timeout=10)
resp.raise_for_status()
data = resp.json()
base_url = data.get("baseUrl")
chapter_hash = data.get("chapter", {}).get("hash")
# 'data' contains full quality, 'dataSaver' contains compressed
filenames = data.get("chapter", {}).get("data", [])
if not base_url or not chapter_hash or not filenames:
print("MangaDex-Module: Incomplete data received from At-Home API.")
return []
# 3. Construct direct image URLs
# Format: {baseUrl}/data/{hash}/{filename}
image_links = [
f"{base_url}/data/{chapter_hash}/{filename}"
for filename in filenames
]
return image_links
except Exception as e:
print(f"MangaDex-Module: Error fetching images: {e}")
return None

8
render.sh Normal file
View File

@@ -0,0 +1,8 @@
#!/bin/bash
# Install system dependencies needed for FPDF/Pillow if necessary
# Render usually has these, but just in case:
# apt-get update && apt-get install -y libgl1
# Start the application using uvicorn
# We use the $PORT variable provided by Render
uvicorn app:app --host 0.0.0.0 --port $PORT

16
requirements.txt Normal file
View File

@@ -0,0 +1,16 @@
fastapi
uvicorn[standard]
requests
beautifulsoup4
httpx
zeroconf
selenium
fpdf2
python-multipart
mangakatana
hmtai
# Auto-generated module & extension requirements
beautifulsoup4
httpx
jinja2
watchdog

485
start Executable file
View File

@@ -0,0 +1,485 @@
#!/bin/bash
#==============================================================================
# Animex Extension Server Startup Script
# Description: Professional startup script with comprehensive error handling,
# logging, and environment management for Animex Extension Server
# Author: Animex Developer (Indie)
# Date: 2023-10-01
# License: GPL-3.0
# Version: 2.0
#==============================================================================
set -euo pipefail # Exit on error, undefined vars, pipe failures
#==============================================================================
# CONFIGURATION
#==============================================================================
readonly SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
readonly VENV_DIR="${SCRIPT_DIR}/venv"
readonly REQUIREMENTS="${SCRIPT_DIR}/requirements.txt"
readonly FIRST_RUN_FLAG="${SCRIPT_DIR}/.first_run_complete"
readonly LOGO_FILE="${SCRIPT_DIR}/logo.txt"
readonly LOG_FILE="${SCRIPT_DIR}/startup.log"
readonly APP_NAME="Animex Extension Server"
readonly APP_HOST="${APP_HOST:-0.0.0.0}"
readonly APP_PORT="${APP_PORT:-7275}"
readonly PYTHON_CMD="${PYTHON_CMD:-python3}"
#==============================================================================
# UTILITY FUNCTIONS
#==============================================================================
# Logging function with timestamps
log() {
local level="$1"
shift
local message="$*"
local timestamp=$(date '+%Y-%m-%d %H:%M:%S')
echo "[$timestamp] [$level] $message" | tee -a "$LOG_FILE"
}
log_info() { log "INFO" "$@"; }
log_warn() { log "WARN" "$@"; }
log_error() { log "ERROR" "$@"; }
# Display colored output
print_colored() {
local color_code="$1"
local message="$2"
echo -e "\033[${color_code}m${message}\033[0m"
}
print_success() { print_colored "1;32" "$1"; }
print_warning() { print_colored "1;33" "$1"; }
print_error() { print_colored "1;31" "$1"; }
print_info() { print_colored "1;36" "$1"; }
print_orange() { print_colored "1;38;5;208" "$1"; }
# Display Animex logo in bright orange
display_logo() {
echo ""
if [[ -f "$LOGO_FILE" ]]; then
print_orange "$(cat "$LOGO_FILE")"
echo ""
print_orange "╔══════════════════════════════════════════════════════════════════════════╗"
print_orange "║ ANIMEX EXTENSION SERVER ║"
print_orange "║ Advanced Anime Streaming Platform ║"
print_orange "╚══════════════════════════════════════════════════════════════════════════╝"
else
print_orange "╔══════════════════════════════════════════════════════════════════════════╗"
print_orange "║ ANIMEX EXTENSION SERVER ║"
print_orange "║ Advanced Anime Streaming Platform ║"
print_orange "╚══════════════════════════════════════════════════════════════════════════╝"
log_warn "Logo file not found: $LOGO_FILE"
fi
echo ""
}
# Check system requirements
check_requirements() {
log_info "Checking system requirements for Animex Extension Server..."
if ! command -v "$PYTHON_CMD" &> /dev/null; then
log_error "Python 3 is not installed or not in PATH"
print_error "ERROR: $PYTHON_CMD command not found. Please install Python 3."
exit 1
fi
local python_version
python_version=$("$PYTHON_CMD" --version 2>&1 | cut -d' ' -f2)
log_info "Found Python version: $python_version"
if ! "$PYTHON_CMD" -m venv --help &> /dev/null; then
log_error "Python venv module is not available"
print_error "ERROR: Python venv module not found. Please install python3-venv."
exit 1
fi
log_info "System requirements check passed for Animex Extension Server"
}
# Create and setup virtual environment
setup_virtual_environment() {
log_info "Setting up Animex Extension Server virtual environment at: $VENV_DIR"
if [[ ! -d "$VENV_DIR" ]]; then
print_info "🔧 Creating Animex virtual environment..."
if ! "$PYTHON_CMD" -m venv "$VENV_DIR"; then
log_error "Failed to create virtual environment"
print_error "ERROR: Failed to create virtual environment"
exit 1
fi
log_info "Animex virtual environment created successfully"
print_success "✓ Animex virtual environment created"
else
log_info "Animex virtual environment already exists"
print_info "🔧 Animex virtual environment already exists"
fi
}
# Activate virtual environment
activate_virtual_environment() {
local activate_script="${VENV_DIR}/bin/activate"
if [[ ! -f "$activate_script" ]]; then
log_error "Virtual environment activation script not found: $activate_script"
print_error "ERROR: Virtual environment is corrupted. Please delete the venv directory and try again."
exit 1
fi
# shellcheck source=/dev/null
source "$activate_script"
log_info "Animex virtual environment activated"
print_success "✓ Animex virtual environment activated"
}
#==============================================================================
# MODULE & EXTENSION REQUIREMENTS MANAGEMENT
#==============================================================================
# Extract requirements from .module files
extract_module_requirements() {
local modules_dir="${SCRIPT_DIR}/modules"
local temp_req_file="$1"
if [[ ! -d "$modules_dir" ]]; then
return 0
fi
while IFS= read -r -d '' module_file; do
local json_header
json_header=$(sed -n '2,/^---$/p' "$module_file" | sed '$d' 2>/dev/null)
if [[ -n "$json_header" ]]; then
local requirements
requirements=$("$PYTHON_CMD" -c "
import json, sys
try:
data = json.loads('''$json_header''')
reqs = data.get('requirements', [])
for req in reqs: print(req)
except: pass
" 2>/dev/null)
if [[ -n "$requirements" ]]; then
echo "$requirements" >> "$temp_req_file"
fi
fi
done < <(find "$modules_dir" -name "*.module" -type f -print0)
}
# Check and free required ports
check_and_free_ports() {
local ports=(7275 7277)
for port in "${ports[@]}"; do
log_info "Checking if port $port is in use..."
local pids
pids=$(lsof -ti tcp:"$port" || true)
if [[ -n "$pids" ]]; then
log_warn "Port $port is already in use by PID(s): $pids"
print_warning "⚠ Port $port is in use. Attempting to stop process(es)..."
# Try graceful shutdown first
kill $pids 2>/dev/null || true
sleep 2
# Force kill if still alive
for pid in $pids; do
if kill -0 "$pid" 2>/dev/null; then
log_warn "PID $pid did not terminate gracefully. Force killing..."
kill -9 "$pid" 2>/dev/null || true
fi
done
sleep 1
fi
# Final verification
if lsof -ti tcp:"$port" &>/dev/null; then
log_error "Port $port is still in use after kill attempts"
print_error "❌ Failed to free port $port. Aborting startup."
exit 1
else
log_info "Port $port is free"
print_success "✓ Port $port is available"
fi
done
}
# Extract requirements from extension package.json files
extract_extension_requirements() {
local extensions_dir="${SCRIPT_DIR}/extensions"
local temp_req_file="$1"
if [[ ! -d "$extensions_dir" ]]; then
return 0
fi
while IFS= read -r -d '' pkg_file; do
local requirements
requirements=$("$PYTHON_CMD" -c "
import json, sys
try:
with open('$pkg_file', 'r') as f:
data = json.load(f)
reqs = data.get('requirements', [])
for req in reqs: print(req)
except: pass
" 2>/dev/null)
if [[ -n "$requirements" ]]; then
echo "$requirements" >> "$temp_req_file"
fi
done < <(find "$extensions_dir" -name "package.json" -type f -print0)
}
# Install Python dependencies with module and extension requirements
install_dependencies() {
local temp_requirements="${SCRIPT_DIR}/.temp_all_requirements.txt"
# Clear temp file
> "$temp_requirements"
# Extract requirements from both modules and extensions
extract_module_requirements "$temp_requirements"
extract_extension_requirements "$temp_requirements"
# Consolidate and count unique requirements
local final_reqs_count=0
if [[ -s "$temp_requirements" ]]; then
sort -u "$temp_requirements" -o "$temp_requirements"
final_reqs_count=$(wc -l < "$temp_requirements")
fi
# Backup original requirements.txt
local backup_requirements="${SCRIPT_DIR}/.requirements_backup.txt"
if [[ -f "$REQUIREMENTS" ]]; then
cp "$REQUIREMENTS" "$backup_requirements"
fi
# Merge all requirements
{
if [[ -f "$backup_requirements" ]]; then
cat "$backup_requirements"
fi
if [[ $final_reqs_count -gt 0 ]]; then
echo ""
echo "# Auto-generated module & extension requirements"
cat "$temp_requirements"
fi
} > "$REQUIREMENTS"
if [[ $final_reqs_count -gt 0 ]]; then
log_info "Merged $final_reqs_count unique requirements from modules/extensions."
print_success "✓ Found and added $final_reqs_count additional requirements."
fi
if [[ ! -f "$REQUIREMENTS" ]]; then
log_warn "Requirements file not found: $REQUIREMENTS"
print_warning "WARNING: requirements.txt not found. Server might not run correctly."
return 0
fi
log_info "Installing/updating dependencies from: $REQUIREMENTS"
print_info "📦 Installing required packages..."
# Upgrade pip first
pip install --upgrade pip >> "$LOG_FILE" 2>&1
# Install all requirements
if ! pip install -r "$REQUIREMENTS" >> "$LOG_FILE" 2>&1; then
log_error "Failed to install dependencies. Check $LOG_FILE for details."
print_error "ERROR: Failed to install packages. Check log file."
# Restore original requirements before exiting
if [[ -f "$backup_requirements" ]]; then mv "$backup_requirements" "$REQUIREMENTS"; fi
rm -f "$temp_requirements"
deactivate 2>/dev/null || true
exit 1
fi
# Restore original requirements.txt
if [[ -f "$backup_requirements" ]]; then
mv "$backup_requirements" "$REQUIREMENTS"
else
# If no backup, just clear the generated part
> "$REQUIREMENTS"
fi
rm -f "$temp_requirements"
log_info "Dependencies installed successfully"
print_success "✓ Dependencies installed"
}
# Perform first-time setup
first_time_setup() {
print_orange "🚀 Performing Animex Extension Server first-time setup..."
log_info "Starting Animex Extension Server first-time setup"
check_requirements
setup_virtual_environment
activate_virtual_environment
install_dependencies
touch "$FIRST_RUN_FLAG"
log_info "Animex Extension Server first-time setup completed"
print_success "✓ Animex Extension Server first-time setup completed"
}
# Start the Animex Extension Server
start_application() {
log_info "Starting $APP_NAME on http://$APP_HOST:$APP_PORT"
print_orange "🎬 Starting Animex Extension Server..."
print_orange "🌐 Server URL: http://$APP_HOST:$APP_PORT"
print_orange "📺 Animex Extension Server is ready for anime streaming!"
print_info "Press CTRL+C to stop the Animex server"
echo ""
# Check if uvicorn is available
if ! command -v uvicorn &> /dev/null; then
log_error "uvicorn not found in virtual environment"
print_error "ERROR: uvicorn is not installed. Please check your requirements.txt"
exit 1
fi
# Check if live reload is enabled
if [[ "${LIVE_RELOAD:-false}" == "true" ]]; then
print_info "🔄 Live reload is enabled - server will restart on file changes"
# Start the server with file watching
python watch.py
else
# Start the server normally
uvicorn app:app --host "$APP_HOST" --port "$APP_PORT" --log-level info
fi
}
# Cleanup function
cleanup() {
local exit_code=$?
log_info "Shutting down Animex Extension Server (exit code: $exit_code)"
# Clean up any remaining temporary requirement files
rm -f "${SCRIPT_DIR}/.temp_module_requirements.txt" "${SCRIPT_DIR}/.requirements_backup.txt"
if [[ -n "${VIRTUAL_ENV:-}" ]]; then
deactivate 2>/dev/null || true
log_info "Animex virtual environment deactivated"
print_info "🔧 Animex virtual environment deactivated"
fi
print_orange "🛑 Animex Extension Server stopped"
log_info "Animex Extension Server shutdown complete"
exit $exit_code
}
# Display help information
show_help() {
cat << EOF
Usage: $0 [OPTIONS]
Animex Extension Server - Advanced Anime Streaming Platform
OPTIONS:
-h, --help Show this help message
--clean Remove virtual environment and start fresh
--check Check system requirements only
--live Enable live reload - server will restart on file changes
--version Show script version
ENVIRONMENT VARIABLES:
APP_HOST Server host (default: 0.0.0.0)
APP_PORT Server port (default: 7275)
PYTHON_CMD Python command (default: python3)
EXAMPLES:
$0 Start Animex Extension Server
$0 --clean Clean install Animex Extension Server
APP_PORT=8080 $0 Start Animex on port 8080
ABOUT ANIMEX:
Animex Extension Server is an advanced anime streaming platform
designed to provide seamless anime content delivery and management.
EOF
}
#==============================================================================
# MAIN EXECUTION
#==============================================================================
main() {
# Parse command line arguments
while [[ $# -gt 0 ]]; do
case $1 in
-h|--help)
show_help
exit 0
;;
--clean)
print_info "🧹 Cleaning Animex virtual environment..."
rm -rf "$VENV_DIR" "$FIRST_RUN_FLAG"
log_info "Clean install requested - removed Animex venv and first run flag"
;;
--check)
check_requirements
print_success "✓ Animex system requirements check passed"
exit 0
;;
--live)
export LIVE_RELOAD=true
print_info "🔄 Live reload enabled"
log_info "Live reload feature enabled"
;;
--version)
echo "Animex Extension Server Startup Script v2.0"
exit 0
;;
*)
print_error "Unknown option: $1"
show_help
exit 1
;;
esac
shift
done
# Set up signal handlers
trap cleanup EXIT INT TERM
# Initialize log file
echo "=== Animex Extension Server Startup - $(date) ===" >> "$LOG_FILE"
# Display Animex logo
display_logo
# Check if this is first run
if [[ ! -f "$FIRST_RUN_FLAG" ]]; then
first_time_setup
else
print_orange "🎉 Welcome back to Animex Extension Server!"
log_info "Returning Animex user detected"
check_requirements
fi
# Activate virtual environment
activate_virtual_environment
# Install/update dependencies
install_dependencies
echo ""
echo "✅ All systems go! Starting Animex Extension Server... Checking ports..."
check_and_free_ports
# Start the Animex Extension Server
start_application
}
# Execute main function with all arguments
main "$@"

158
templates/download.html Normal file
View File

@@ -0,0 +1,158 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0, user-scalable=no">
<title>Downloading Manga...</title>
<style>
:root {
--background-color: #121212;
--text-color: #EAEAEA;
--accent-color: #FF9500;
--spinner-track-color: #444;
}
body {
margin: 0;
background-color: var(--background-color);
color: var(--text-color);
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif;
display: flex;
justify-content: center;
align-items: center;
height: 100vh;
text-align: center;
padding: 20px;
}
.container {
display: flex;
flex-direction: column;
align-items: center;
gap: 20px;
}
#spinner {
width: 50px;
height: 50px;
border: 5px solid var(--spinner-track-color);
border-top-color: var(--accent-color);
border-radius: 50%;
animation: spin 1.2s linear infinite;
}
@keyframes spin {
to { transform: rotate(360deg); }
}
h1 {
font-size: 22px;
font-weight: 600;
margin: 0;
}
p {
font-size: 16px;
color: #aaa;
margin: 0;
}
#status-text {
margin-top: 10px;
}
#error-message {
color: #ff4545;
display: none;
margin-top: 1rem;
max-width: 400px;
word-wrap: break-word;
}
</style>
</head>
<body>
<div class="container">
<div id="spinner"></div>
<div id="status-text">
<h1 id="title-text">Grabbing Chapter for you...</h1>
<p id="details-text">Your download will begin shortly.</p>
<p id="error-message"></p>
</div>
</div>
<script>
document.addEventListener('DOMContentLoaded', () => {
const pathParts = window.location.pathname.split('/');
const source = pathParts[3];
const mangaId = pathParts[4];
const chapterId = pathParts[5];
const titleText = document.getElementById('title-text');
const detailsText = document.getElementById('details-text');
const errorMessage = document.getElementById('error-message');
const spinner = document.getElementById('spinner');
// --- Update Title ---
if (source === 'jikan') {
fetch(`https://api.jikan.moe/v4/manga/${mangaId}`)
.then(res => res.json())
.then(data => {
const title = data.data?.title || 'this manga';
titleText.textContent = `Grabbing Chapter ${chapterId} of ${title}`;
})
.catch(err => console.error("Failed to fetch manga title:", err));
} else if (source === 'mangadex') {
const mangaDetailsPromise = fetch(`/mangadex/manga/${mangaId}`).then(res => res.json());
const chapterDetailsPromise = fetch(`/mangadex/manga/${mangaId}/chapter-nav-details/${chapterId}`).then(res => res.json());
Promise.all([mangaDetailsPromise, chapterDetailsPromise])
.then(([mangaData, chapterNavData]) => {
const title = mangaData?.attributes?.title?.en || 'this manga';
const chapterNumber = chapterNavData?.current_chapter?.attributes?.chapter || chapterId;
titleText.textContent = `Grabbing Chapter ${chapterNumber} of ${title}`;
})
.catch(err => {
console.error("Failed to fetch manga/chapter title:", err);
titleText.textContent = `Grabbing Chapter for you...`;
});
}
// --- Trigger Download ---
const downloadUrl = `/download-manga/direct/${source}/${mangaId}/${chapterId}`;
fetch(downloadUrl)
.then(async res => {
if (!res.ok) {
const errorData = await res.json().catch(() => null);
const detail = errorData?.detail || `Server responded with status ${res.status}`;
throw new Error(detail);
}
const disposition = res.headers.get('content-disposition');
let filename = `chapter-${chapterId}.pdf`;
if (disposition && disposition.includes('attachment')) {
const filenameMatch = /filename="([^"]+)"/.exec(disposition);
if (filenameMatch && filenameMatch[1]) {
filename = filenameMatch[1];
}
}
return res.blob().then(blob => ({ blob, filename }));
})
.then(({ blob, filename }) => {
const url = window.URL.createObjectURL(blob);
const a = document.createElement('a');
a.style.display = 'none';
a.href = url;
a.download = filename;
document.body.appendChild(a);
a.click();
window.URL.revokeObjectURL(url);
a.remove();
spinner.style.display = 'none';
detailsText.textContent = "Download started! You can close this page.";
})
.catch(err => {
console.error('Download failed:', err);
spinner.style.display = 'none';
titleText.textContent = 'Download Failed';
detailsText.textContent = 'Could not prepare your download.';
errorMessage.textContent = `Error: ${err.message}. Please try again later.`;
errorMessage.style.display = 'block';
});
});
</script>
</body>
</html>

28673
templates/map.json Normal file

File diff suppressed because it is too large Load Diff

199
templates/pdf_reader.html Normal file
View File

@@ -0,0 +1,199 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0, user-scalable=no">
<title>PDF Reader</title>
<script src="https://cdnjs.cloudflare.com/ajax/libs/pdf.js/2.11.338/pdf.min.js"></script>
<style>
:root {
--background-color: #121212;
--text-color: #EAEAEA;
--accent-color: #FF9500;
--header-bg: rgba(20, 20, 22, 0.85);
--border-color: rgba(255, 255, 255, 0.1);
}
body {
margin: 0;
background-color: var(--background-color);
color: var(--text-color);
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif;
overflow-y: scroll;
}
#reader-header {
position: fixed;
top: 0;
left: 0;
right: 0;
z-index: 100;
background-color: var(--header-bg);
backdrop-filter: blur(14px);
-webkit-backdrop-filter: blur(14px);
border-bottom: 1px solid var(--border-color);
display: flex;
justify-content: space-between;
align-items: center;
height: 55px;
padding: 0 20px;
}
#pdf-title {
font-size: 17px;
font-weight: 600;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
#page-indicator {
font-size: 14px;
color: #ccc;
}
#loader {
position: fixed;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
width: 40px;
height: 40px;
border: 4px solid #444;
border-top-color: var(--accent-color);
border-radius: 50%;
animation: spin 1s linear infinite;
}
@keyframes spin {
to { transform: translate(-50%, -50%) rotate(360deg); }
}
#pdf-container {
padding-top: 70px; /* Space for header */
display: flex;
flex-direction: column;
align-items: center;
gap: 10px;
}
#pdf-container canvas {
max-width: 100%;
height: auto;
box-shadow: 0 4px 15px rgba(0,0,0,0.3);
}
</style>
</head>
<body>
<header id="reader-header">
<h1 id="pdf-title">Loading PDF...</h1>
<div id="page-indicator">Page 1 / ?</div>
</header>
<div id="loader"></div>
<main id="pdf-container"></main>
<script>
// Set worker source for PDF.js
pdfjsLib.GlobalWorkerOptions.workerSrc = `https://cdnjs.cloudflare.com/ajax/libs/pdf.js/2.11.338/pdf.worker.min.js`;
const container = document.getElementById('pdf-container');
const loader = document.getElementById('loader');
const titleEl = document.getElementById('pdf-title');
const pageIndicator = document.getElementById('page-indicator');
let pdfDoc = null;
let currentPage = 1;
let totalPages = 0;
// --- IndexedDB Functions ---
const DB_NAME = 'PDFLibraryDB';
const DB_VERSION = 1;
const STORE_NAME = 'pdfs';
function openDB() {
return new Promise((resolve, reject) => {
const request = indexedDB.open(DB_NAME, DB_VERSION);
request.onerror = (e) => reject("Error opening DB: " + e.target.errorCode);
request.onsuccess = (e) => resolve(e.target.result);
request.onupgradeneeded = (e) => {
if (!e.target.result.objectStoreNames.contains(STORE_NAME)) {
e.target.result.createObjectStore(STORE_NAME, { keyPath: 'id', autoIncrement: true });
}
};
});
}
async function getPdfFromDB(id) {
const db = await openDB();
const transaction = db.transaction(STORE_NAME, 'readonly');
const store = transaction.objectStore(STORE_NAME);
return new Promise((resolve, reject) => {
const request = store.get(Number(id));
request.onsuccess = (e) => resolve(e.target.result);
request.onerror = (e) => reject("Error fetching PDF: " + e.target.errorCode);
});
}
async function renderPdf(pdfData) {
const loadingTask = pdfjsLib.getDocument({ data: pdfData });
pdfDoc = await loadingTask.promise;
totalPages = pdfDoc.numPages;
loader.style.display = 'none';
pageIndicator.textContent = `Page 1 / ${totalPages}`;
for (let pageNum = 1; pageNum <= totalPages; pageNum++) {
const page = await pdfDoc.getPage(pageNum);
const viewport = page.getViewport({ scale: 2.0 });
const canvas = document.createElement('canvas');
const context = canvas.getContext('2d');
canvas.height = viewport.height;
canvas.width = viewport.width;
container.appendChild(canvas);
await page.render({ canvasContext: context, viewport: viewport }).promise;
}
}
function updatePageIndicator() {
let mostVisiblePage = 1;
const canvases = container.getElementsByTagName('canvas');
let maxVisibility = 0;
for (let i = 0; i < canvases.length; i++) {
const rect = canvases[i].getBoundingClientRect();
const visibleHeight = Math.max(0, Math.min(rect.bottom, window.innerHeight) - Math.max(rect.top, 0));
const visibility = visibleHeight / rect.height;
if (visibility > maxVisibility) {
maxVisibility = visibility;
mostVisiblePage = i + 1;
}
}
pageIndicator.textContent = `Page ${mostVisiblePage} / ${totalPages}`;
}
async function init() {
const urlParams = new URLSearchParams(window.location.search);
const pdfId = urlParams.get('id');
if (!pdfId) {
titleEl.textContent = "No PDF specified.";
loader.style.display = 'none';
return;
}
try {
const pdfRecord = await getPdfFromDB(pdfId);
if (pdfRecord && pdfRecord.file) {
titleEl.textContent = pdfRecord.name;
const pdfBytes = await pdfRecord.file.arrayBuffer();
await renderPdf(pdfBytes);
window.addEventListener('scroll', updatePageIndicator);
} else {
throw new Error("PDF not found in offline library.");
}
} catch (error) {
console.error("Failed to load PDF:", error);
titleEl.textContent = "Error loading PDF";
loader.style.display = 'none';
}
}
document.addEventListener('DOMContentLoaded', init);
</script>
</body>
</html>

Some files were not shown because too many files have changed in this diff Show More