- 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)
424 lines
13 KiB
HTML
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>
|