sto-scraper/data/index.html
denode 75221c9747 Scraper for serienstream.to (s.to) + series lister
- Migrated sto scraper to Playwright Firefox (puppeteer 24 is Chromium-only
  and DDOS-Guard blocks Chromium)
- Handle tab-scoped auth by reusing the authenticated page
- Add npm run list:series to summarize scraped data (series + watched eps)
2026-09-01 09:05:17 +02:00

424 lines
13 KiB
HTML

<!doctype html>
<html lang="de">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Watch History</title>
<!-- Vuetify 3 CSS -->
<link
href="https://cdn.jsdelivr.net/npm/vuetify@4.0.5/dist/vuetify.min.css"
rel="stylesheet"
/>
<!-- Material Design Icons -->
<link
href="https://cdn.jsdelivr.net/npm/@mdi/font@7.4.47/css/materialdesignicons.min.css"
rel="stylesheet"
/>
<style>
body {
margin: 0;
padding: 0;
font-family: "Roboto", sans-serif;
}
.search-bar-sticky {
position: sticky;
top: 48px;
z-index: 100;
/*noinspection CssUnresolvedCustomProperty*/
background: rgb(var(--v-theme-surface));
border-bottom: 1px solid
rgba(var(--v-border-color), var(--v-border-opacity));
}
.episode-list .v-row:nth-child(even) {
background-color: rgba(255, 255, 255, 0.025);
}
.episode-list .v-row:nth-child(odd) {
background-color: rgba(255, 255, 255, 0.01);
}
.episode-list .v-row {
transition: background-color 0.15s;
}
.episode-list .v-row:hover {
background-color: rgba(255, 255, 255, 0.05);
}
.stats-chip {
font-weight: 500;
}
.no-data-container {
min-height: 60vh;
display: flex;
align-items: center;
justify-content: center;
}
/* Panel title: truncate long titles, keep chips visible */
.panel-title-inner {
min-width: 0;
display: flex;
align-items: center;
flex-wrap: wrap;
gap: 4px;
width: 100%;
}
.panel-title-text {
min-width: 0;
flex: 1 1 200px;
overflow: hidden;
text-wrap-style: balance;
line-height: 1.4;
}
.panel-title-chips {
flex-shrink: 0;
display: flex;
gap: 8px;
align-items: center;
/* keep chips away from expansion arrow */
margin-left: 8px;
margin-right: 8px;
}
@media (max-width: 600px) {
.panel-title-chips {
display: none;
}
}
</style>
</head>
<body>
<div id="app">
<v-app>
<v-app-bar color="primary" density="compact">
<template v-slot:prepend>
<v-icon>mdi-movie-open</v-icon>
</template>
<v-app-bar-title>
Watch History
<small>
|
<a
class="text-black"
href="https://github.com/Kamiikaze/sto-scraper"
>Kamiikaze/sto-scraper</a
></small
>
</v-app-bar-title>
<template v-slot:append>
<v-btn icon @click="toggleTheme">
<v-icon
>{{ isDark ? 'mdi-white-balance-sunny' : 'mdi-weather-night'
}}</v-icon
>
</v-btn>
</template>
</v-app-bar>
<v-main>
<!-- Sticky Bar -->
<div class="search-bar-sticky">
<v-container>
<v-row align="center" no-gutters class="gap-2 py-1">
<!-- List Type Toggle -->
<v-col cols="auto" class="d-flex align-center mr-2">
<v-btn-toggle
v-model="listType"
color="primary"
density="comfortable"
mandatory
border
>
<v-btn value="serie">Serien</v-btn>
<v-btn value="anime">Animes</v-btn>
</v-btn-toggle>
</v-col>
<!-- Search Field -->
<v-col class="d-flex align-center">
<v-text-field
v-model="searchQuery"
prepend-inner-icon="mdi-magnify"
placeholder="Titel suchen…"
density="compact"
variant="outlined"
hide-details
clearable
class="w-100"
></v-text-field>
</v-col>
</v-row>
<v-row
class="d-flex justify-center align-center mt-3 ga-2"
v-if="filteredSeries.length > 0"
>
<!-- Stats Chips (hidden below lg) -->
<v-col cols="auto">
<v-chip class="stats-chip" color="secondary" variant="flat">
<v-icon start>mdi-web</v-icon>
{{ currentData.site || 'Unknown' }}
</v-chip>
</v-col>
<v-col cols="auto">
<v-chip class="stats-chip" color="primary" variant="flat">
<v-icon start>mdi-television-classic</v-icon>
{{ currentData.totalMovies }} Shows
</v-chip>
</v-col>
<v-col cols="auto">
<v-chip class="stats-chip" color="warning" variant="flat">
<v-icon start>mdi-television-classic</v-icon>
{{ currentData.totalEpisodesCount }} Episoden
</v-chip>
</v-col>
<v-col cols="auto">
<v-chip
class="stats-chip"
color="info"
variant="flat"
v-if="currentData.scrapedAt"
>
<v-icon start>mdi-clock</v-icon>
{{ formatDate(currentData.scrapedAt) }}
</v-chip>
</v-col>
</v-row>
</v-container>
</div>
<!-- Content Area -->
<v-container class="py-6">
<!-- No data state -->
<div
v-if="!loading && filteredSeries.length === 0"
class="no-data-container"
>
<v-card class="text-center pa-8" elevation="2" max-width="500">
<v-icon size="80" color="primary" class="mb-4"
>mdi-alert-circle</v-icon
>
<h2 class="text-h5 mb-4">
{{ searchQuery ? 'Keine Treffer' : 'Keine Daten gefunden' }}
</h2>
<p class="text-body-1 mb-4" v-if="!searchQuery">
Überprüfe, ob im <code>./data</code> Ordner eine
<code>{seite}_latest.json</code> Datei vorhanden ist.
</p>
<p class="text-body-1 mb-4" v-else>
Kein Titel enthält „{{ searchQuery }}".
</p>
</v-card>
</div>
<!-- Series List -->
<v-expansion-panels v-if="!loading && filteredSeries.length > 0">
<v-expansion-panel
v-for="series in filteredSeries"
:key="series.title"
elevation="3"
>
<v-expansion-panel-title>
<div class="panel-title-inner">
<v-icon class="mr-1 flex-shrink-0" color="primary"
>mdi-movie-roll</v-icon
>
<span class="panel-title-text text-h6"
>{{ series.title }}</span
>
<div class="panel-title-chips">
<v-chip size="small" color="primary" variant="outlined">
{{ series.seasonCount }} Staffel{{ series.seasonCount
!== 1 ? 'n' : '' }}
</v-chip>
<v-chip size="small" color="secondary" variant="outlined">
{{ series.totalEpisodesCount }} Episode{{
series.totalEpisodesCount !== 1 ? 'n' : '' }}
</v-chip>
</div>
</div>
</v-expansion-panel-title>
<v-expansion-panel-text>
<v-divider class="mb-4"></v-divider>
<div
class="episode-list mb-6"
v-for="season in series.seasons"
:key="season.num"
>
<h2 class="mb-2">
{{ season.num === 0 ? "Film / Specials" : `Staffel
${season.num}` }}
</h2>
<v-row
class="mt-0 py-2 px-2"
v-for="episode in season.episodes"
:key="episode.num"
>
<v-col cols="2" sm="1">{{ episode.num }}</v-col>
<v-col cols="10" sm="8">{{ episode.title }}</v-col>
<v-col cols="3" class="d-none d-sm-flex justify-end"
>{{ episode.seenAt }}</v-col
>
</v-row>
</div>
</v-expansion-panel-text>
</v-expansion-panel>
</v-expansion-panels>
<!-- Loading -->
<div v-if="loading" class="d-flex justify-center align-center">
<v-progress-circular
indeterminate
color="primary"
></v-progress-circular>
<span class="ml-4">Daten werden geladen...</span>
</div>
</v-container>
</v-main>
</v-app>
</div>
<!-- Vue 3 -->
<script src="https://cdn.jsdelivr.net/npm/vue@3.5.32/dist/vue.global.js"></script>
<!-- Vuetify 3 -->
<script src="https://cdn.jsdelivr.net/npm/vuetify@4.0.5/dist/vuetify.min.js"></script>
<script>
const { createApp } = Vue;
const { createVuetify } = Vuetify;
const vuetify = createVuetify({
theme: {
defaultTheme: "dark",
themes: {
dark: {
colors: {
primary: "#BB86FC",
secondary: "#03DAC6",
accent: "#CF6679",
error: "#CF6679",
info: "#2196F3",
success: "#4CAF50",
warning: "#FB8C00",
},
},
light: {
colors: {
primary: "#6200EE",
secondary: "#03DAC6",
accent: "#018786",
error: "#B00020",
info: "#2196F3",
success: "#4CAF50",
warning: "#FB8C00",
},
},
},
},
});
createApp({
data() {
return {
animeList: null,
serieList: null,
processedAnimeList: [],
processedSerieList: [],
listType: "serie",
searchQuery: "",
isDark: true,
loading: true,
};
},
computed: {
currentData() {
return this.listType === "serie"
? this.serieList || {}
: this.animeList || {};
},
filteredSeries() {
const list =
this.listType === "serie"
? this.processedSerieList
: this.processedAnimeList;
if (!this.searchQuery?.trim()) return list;
const q = this.searchQuery.toLowerCase();
return list.filter((s) => s.title.toLowerCase().includes(q));
},
},
methods: {
async fetchData(url) {
try {
const response = await fetch(url);
if (!response.ok) {
throw new Error(`HTTP error! status: ${response.status}`);
}
return await response.json();
} catch (error) {
console.error("Error fetching data:", error);
return null;
}
},
formatDate(dateString) {
return new Date(dateString).toLocaleString("de-DE", {
year: "numeric",
month: "2-digit",
day: "2-digit",
hour: "2-digit",
minute: "2-digit",
});
},
processData(raw) {
if (!raw?.movieTitles) return [];
return Object.entries(raw.movieTitles).map(([title, data]) => ({
title,
seasonCount: data.seasonCount,
totalEpisodesCount: data.totalEpisodesCount,
seasons: Object.entries(data.seasons).map(([sNum, season]) => ({
num: parseInt(sNum),
episodes: Object.entries(season.episodes).map(
([epNum, ep]) => ({
num: epNum,
title: ep.title,
seenAt: this.formatDate(ep.seenAt),
}),
),
})),
}));
},
toggleTheme() {
this.isDark = !this.isDark;
this.$vuetify.theme.global.name = this.isDark ? "dark" : "light";
},
},
async mounted() {
this.loading = true;
const [anime, serie] = await Promise.all([
this.fetchData("./aniworld.to_latest.json"),
this.fetchData("./s.to_latest.json"),
]);
this.animeList = anime;
this.serieList = serie;
this.processedAnimeList = this.processData(anime);
this.processedSerieList = this.processData(serie);
this.loading = false;
},
})
.use(vuetify)
.mount("#app");
</script>
</body>
</html>