Francesco Grazioso 1c5f960b16
Feat/app api and frontend (#140)
* minor fixes

* created basic django app

* add django dependency

* created basic search endpoint

* created retrieve method for search

* remove retrieve

* start implementing download endpoint (only movie for now)

* start implementing episode info for series

* finished get_episodes_info

* minor fixes

* add download anime episode

* start implementing download for tv series

* refactor methods

* finished download endpoint (will implement possibility to download single episodes of season in tv series)

* new domain and black on project

* start

* add cors

* add start gui command

* add gui for search

* edited .gitignore

* create component for media details

* better UX/UI

* edited anime episode to stream response (better experience)

* implemented UI for media details (TODO add download capabilities)

* fix poster fetching

* minor fixes

* fix cors error

* start implementing download

* fix typing on anime movies

* refactor

* refactor + add download OVA

* add plot for all anime types

* add download for all anime episodes

* add download all tv series episodes

* fix crach if localStorage is undefined

* moved download logic in separeted file

* fix wrong index passed while downloading tv series

* fix style searchbar

* add loader to search button and add enter listener while searching

* remove dependency from loading episodes to download all in anime series

* add function to download selected episodes for anime

* add sh command to kill gui

* fix messages in kill_gui.sh

* start implementing download select episodes for tv series (to be tested) + run black and eslint

* start  refactoring  to version 2.0

* start implementing preview endpoint

* finish reimplement version 2.0
2024-06-01 13:13:09 +02:00

335 lines
9.0 KiB
Vue

<script setup lang="ts">
import { useRoute } from 'vue-router'
import type {Episode, MediaItem, SeasonResponse} from "@/api/interfaces";
import { onBeforeMount, onMounted, ref } from "vue";
import { getEpisodesInfo, getPreview } from "@/api/api";
import {
alertDownload,
handleMovieDownload,
handleOVADownload,
handleTVAnimeDownload,
handleTvAnimeEpisodesDownload,
handleTVDownload, handleTVEpisodesDownload
} from "@/api/utils";
const route = useRoute()
const item: MediaItem = JSON.parse(<string>route.params.item)
const imageUrl: string = <string>route.params.imageUrl
const animeEpisodes = ref<Episode[]>([])
const totalEpisodes = ref<number>(0)
const tvShowEpisodes = ref<any[]>([])
const loading = ref(false)
const selectingEpisodes = ref(false)
const selectedEpisodes = ref<Episode[]>([])
const previewItem = ref<MediaItem>(item)
onBeforeMount(async () => {
const res = await getPreview(item.id, item.slug, item.type)
if (res && res.data) {
previewItem.plot = res.data.plot
}
})
onMounted(async () => {
loading.value = true;
if (['MOVIE', 'OVA', 'SPECIAL'].includes(item.type)) {
loading.value = false;
return
} else {
const response = await getEpisodesInfo(item.id, item.slug, item.type)
if (response && response.body) {
loading.value = false;
const reader = response.body.pipeThrough(new TextDecoderStream()).getReader();
while (true) {
const {value, done} = await reader.read();
if (done) {
window.scrollTo(0, 0)
break;
}
if (item.type === 'TV_ANIME') {
const episodesData:Episode = JSON.parse(value.trim());
animeEpisodes.value.push(episodesData);
totalEpisodes.value = episodesData.episode_total;
} else {
const episodesData:SeasonResponse = JSON.parse(value.trim());
for (const seasonKey in episodesData.episodes) {
const season = episodesData.episodes[seasonKey];
const episodes:Episode[] = [];
for (const episodeKey in season) {
const episode:Episode = season[episodeKey];
episodes.push(episode);
}
tvShowEpisodes.value.push(episodes);
}
}
}
}
}
})
const toggleEpisodeSelection = () => {
selectingEpisodes.value = !selectingEpisodes.value
selectedEpisodes.value = []
}
const toggleEpisodeSelect = (episode: Episode, seasonNumber?: number) => {
if (selectedEpisodes.value.includes(episode)) {
selectedEpisodes.value = selectedEpisodes.value.filter(e => e !== episode)
} else {
if (seasonNumber) {
episode.season_index = seasonNumber
}
selectedEpisodes.value.push(episode)
}
}
const downloadSelectedEpisodes = async () => {
try {
switch (item.type) {
case 'TV':
await handleTVEpisodesDownload(selectedEpisodes.value, item);
case 'TV_ANIME':
await handleTvAnimeEpisodesDownload(selectedEpisodes.value, item);
break;
default:
throw new Error('Tipo di media non supportato');
}
toggleEpisodeSelection();
} catch (error) {
alertDownload(error);
}
};
const downloadAllItems = async () => {
try {
switch (item.type) {
case 'TV':
await handleTVDownload(tvShowEpisodes.value, item);
case 'MOVIE':
await handleMovieDownload(item);
break;
case 'TV_ANIME':
await handleTVAnimeDownload(totalEpisodes.value, item);
break;
case 'OVA':
case 'SPECIAL':
await handleOVADownload(item);
break;
default:
throw new Error('Tipo di media non supportato');
}
} catch (error) {
alertDownload(error);
}
};
</script>
<template>
<div class="details-container">
<div class="details-card">
<!--HEADER SECTION-->
<div class="details-header">
<img :src="imageUrl" :alt="item.name" class="details-image" />
<div class="details-title-container">
<h1 class="details-title">{{ item.name }}</h1>
<h3> {{ item.score }}</h3>
<div class="details-description">
<p>{{ item.plot }}</p>
</div>
<h3 v-if="animeEpisodes.length > 0 && !loading">Numero episodi: {{ totalEpisodes }}</h3>
<h3 v-if="tvShowEpisodes.length > 0 && !loading">Numero stagioni: {{ tvShowEpisodes.length }}</h3>
<hr style="opacity: 0.2; margin-top: 10px"/>
<!--DOWNLOAD SECTION-->
<div class="download-section">
<button :disabled="loading || selectingEpisodes"
@click.prevent="downloadAllItems">
Scarica {{['TV_ANIME', 'TV'].includes(item.type)? 'tutto' : ''}}
</button>
<!--<template v-if="!loading && ['TV_ANIME', 'TV'].includes(item.type)">
<button @click="toggleEpisodeSelection">
{{selectingEpisodes ? 'Disattiva' : 'Attiva'}} selezione episodi
</button>
<button :disabled="selectedEpisodes.length == 0"
@click="downloadSelectedEpisodes">
Download episodi selezionati
</button>
</template>-->
</div>
</div>
</div>
<!--SERIES SECTION-->
<div v-if="!loading && ['TV_ANIME', 'TV'].includes(item.type)"
:class="item.type == 'TV_ANIME' ? 'episodes-container' : 'season-container'">
<div v-if="animeEpisodes.length == 0 && tvShowEpisodes.length == 0">
<p>Non ci sono episodi...</p>
</div>
<div v-else-if="item.type == 'TV_ANIME'"
v-for="episode in animeEpisodes"
:key="episode.id"
class="episode-item"
:style="{ backgroundColor: selectedEpisodes.includes(episode) ? '#42b883' : '#333' }"
@click="selectingEpisodes ? toggleEpisodeSelect(episode) : null">
<div class="episode-title">Episodio {{ episode.number }}</div>
</div>
<div v-else-if="item.type == 'TV'" v-for="(season, index) in tvShowEpisodes" v-bind:key="season.number" class="season-item">
<div class="season-title">Stagione {{ index + 1 }}</div>
<div class="episode-container">
<div v-for="episode in season" :key="episode.id">
<div class="episode-item"
:style="{ backgroundColor: selectedEpisodes.includes(episode) ? '#42b883' : '#333' }"
@click="selectingEpisodes ? toggleEpisodeSelect(episode, index) : null">
<div class="episode-title">
Episodio {{ episode.number }} -
{{episode.name.slice(0, 40) + (episode.name.length > 39 ? '...' : '')}}
</div>
</div>
</div>
</div>
</div>
</div>
<!--MOVIES SECTION-->
<div v-else-if="!loading && ['MOVIE', 'OVA', 'SPECIAL'].includes(item.type)">
<!-- WILL BE POPULATED WITH HYPOTETICAL MOVIES CONTENT -->
</div>
<!--LOADING SECTION-->
<div v-else-if="loading">
<p>Loading...</p>
</div>
</div>
</div>
</template>
<style scoped>
h3 {
padding-top: 10px;
padding-bottom: 10px;
font-weight: bold;
}
.details-container {
padding-top: 10px;
justify-content: center;
align-items: center;
min-height: 100vh;
width: 200%;
color: #fff;
}
.details-card {
width: 100%;
max-width: 1200px;
background-color: #232323;
padding: 2rem;
border-radius: 0.5rem;
}
.details-header {
display: flex;
align-items: flex-start;
margin-bottom: 2rem;
}
.details-image {
width: 295px;
margin-right: 2rem;
border-radius: 0.5rem;
}
@media (max-width: 1008px) {
.details-container {
width: 100%;
}
.details-header {
flex-direction: column;
align-items: center;
}
.details-image {
max-width: 100%;
margin-right: 0;
margin-bottom: 1rem;
}
}
.details-title-container {
flex: 1;
}
.details-title {
font-size: 2rem;
margin-bottom: 1rem;
}
.details-description {
line-height: 1.5;
}
.episodes-container {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
gap: 1rem;
}
.episode-item {
background-color: #333;
padding: 1rem;
border-radius: 0.5rem;
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
transition: transform 0.3s ease;
cursor: pointer;
}
.season-item {
background-color: #2a2a2a;
padding: 1rem;
margin-top: 5px;
margin-bottom: 5px;
border-radius: 0.5rem;
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
}
.season-item div {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(400px, 1fr));
gap: 1rem;
}
.season-title {
font-size: 1.5rem;
font-weight: bold;
padding-bottom: 15px;
}
.episode-item:hover {
transform: translateY(-5px);
}
.episode-title {
font-size: 1.2rem;
font-weight: bold;
}
.download-section {
margin-top: 1rem;
flex: fit-content;
flex-direction: row;
button {
margin-right: 5px;
}
}
@media (max-width: 768px) {
.episodes-container {
grid-template-columns: repeat(auto-fit, minmax(150px, 1fr));
}
.season-item div {
grid-template-columns: repeat(auto-fit, minmax(250px, 1fr));
}
}
</style>