Feat/app api and frontend (#136)

* 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
This commit is contained in:
Francesco Grazioso 2024-06-01 11:59:11 +02:00 committed by GitHub
parent 7f6799d276
commit 306377a7be
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
53 changed files with 5234 additions and 3 deletions

13
.gitignore vendored
View File

@ -42,6 +42,14 @@ local_settings.py
db.sqlite3
db.sqlite3-journal
# Vue stuff:
frontend/dist/
frontend/node_modules/
frontend/npm-debug.log
frontend/.vite/
frontend/.vscode/
# Jupyter Notebook
.ipynb_checkpoints
@ -55,4 +63,7 @@ env.bak/
venv.bak/
# Other
Video
Video
.idea/
.vscode/
.DS_Store

View File

@ -45,6 +45,21 @@ class Episode:
def __str__(self):
return f"Episode(id={self.id}, number={self.number}, name='{self.name}', plot='{self.plot}', duration={self.duration} sec)"
def to_dict(self) -> Dict[str, Any]:
return {
"id": self.id,
"number": self.number,
"name": self.name,
"plot": self.plot,
"duration": self.duration,
"scws_id": self.scws_id,
"season_id": self.season_id,
"created_by": self.created_by,
"created_at": self.created_at,
"updated_at": self.updated_at,
"images": [image.__dict__ for image in self.images]
}
class EpisodeManager:

View File

@ -60,4 +60,17 @@ class PreviewManager:
images_str = "\n".join(str(image) for image in self.images)
return f"Title: ID={self.id}, Type={self.type}, Runtime={self.runtime}, Release Date={self.release_date}, Quality={self.quality}, Plot={self.plot}, Seasons Count={self.seasons_count}\nGenres:\n{genres_str}\nPreview:\n{self.preview}\nImages:\n{images_str}"
def to_dict(self):
return {
"id": self.id,
"type": self.type,
"runtime": self.runtime,
"release_date": self.release_date,
"quality": self.quality,
"plot": self.plot,
"seasons_count": self.seasons_count,
"genres": [genre.__dict__ for genre in self.genres],
"preview": self.preview.__dict__,
"images": [image.__dict__ for image in self.images]
}

View File

@ -37,10 +37,33 @@ class MediaItem:
self.last_air_date: str = data.get('last_air_date')
self.seasons_count: int = data.get('seasons_count')
self.images: List[Image] = [Image(image_data) for image_data in data.get('images', [])]
self.comment: str = data.get('comment')
self.plot: str = data.get('plot')
def __str__(self):
return f"MediaItem(id={self.id}, slug='{self.slug}', name='{self.name}', type='{self.type}', score='{self.score}', sub_ita={self.sub_ita}, last_air_date='{self.last_air_date}', seasons_count={self.seasons_count}, images={self.images})"
@property
def to_dict(self) -> dict:
"""
Convert the MediaItem to a dictionary.
Returns:
dict: The MediaItem as a dictionary.
"""
return {
"id": self.id,
"slug": self.slug,
"name": self.name,
"type": self.type.upper(),
"score": self.score,
"sub_ita": self.sub_ita,
"last_air_date": self.last_air_date,
"seasons_count": self.seasons_count,
"images": [image.__dict__ for image in self.images],
"comment": self.comment,
"plot": self.plot
}
class MediaManager:
def __init__(self):

View File

@ -27,6 +27,19 @@ class Image:
def __str__(self):
return f"Image(id={self.id}, filename='{self.filename}', type='{self.type}', imageable_type='{self.imageable_type}', url='{self.url}')"
def to_dict(self) -> Dict[str, Any]:
return {
'id': self.id,
'filename': self.filename,
'type': self.type,
'imageable_type': self.imageable_type,
'imageable_id': self.imageable_id,
'created_at': self.created_at,
'updated_at': self.updated_at,
'original_url_field': self.original_url_field,
'url': self.url
}
class Episode:
@ -45,6 +58,21 @@ class Episode:
def __str__(self):
return f"Episode(id={self.id}, number={self.number}, name='{self.name}', plot='{self.plot}', duration={self.duration} sec)"
def to_dict(self) -> Dict[str, Any]:
return {
'id': self.id,
'number': self.number,
'name': self.name,
'plot': self.plot,
'duration': self.duration,
'scws_id': self.scws_id,
'season_id': self.season_id,
'created_by': self.created_by,
'created_at': self.created_at,
'updated_at': self.updated_at,
'images': [image.to_dict() for image in self.images]
}
class EpisodeManager:

View File

@ -60,4 +60,17 @@ class PreviewManager:
images_str = "\n".join(str(image) for image in self.images)
return f"Title: ID={self.id}, Type={self.type}, Runtime={self.runtime}, Release Date={self.release_date}, Quality={self.quality}, Plot={self.plot}, Seasons Count={self.seasons_count}\nGenres:\n{genres_str}\nPreview:\n{self.preview}\nImages:\n{images_str}"
def to_dict(self):
return {
"id": self.id,
"type": self.type,
"runtime": self.runtime,
"release_date": self.release_date,
"quality": self.quality,
"plot": self.plot,
"seasons_count": self.seasons_count,
"genres": [genre.__dict__ for genre in self.genres],
"preview": self.preview.__dict__,
"images": [image.__dict__ for image in self.images]
}

View File

@ -37,10 +37,44 @@ class MediaItem:
self.last_air_date: str = data.get('last_air_date')
self.seasons_count: int = data.get('seasons_count')
self.images: List[Image] = [Image(image_data) for image_data in data.get('images', [])]
self.comment: str = data.get('comment')
self.plot: str = data.get('plot')
def __str__(self):
return f"MediaItem(id={self.id}, slug='{self.slug}', name='{self.name}', type='{self.type}', score='{self.score}', sub_ita={self.sub_ita}, last_air_date='{self.last_air_date}', seasons_count={self.seasons_count}, images={self.images})"
@property
def to_dict(self) -> dict:
"""
Convert the MediaItem to a dictionary.
Returns:
dict: The MediaItem as a dictionary.
"""
return {
"id": self.id,
"slug": self.slug,
"name": self.name,
"type": self.type.upper(),
"score": self.score,
"sub_ita": self.sub_ita,
"last_air_date": self.last_air_date,
"seasons_count": self.seasons_count,
"images": [image.__dict__ for image in self.images],
"comment": self.comment,
"plot": self.plot
}
@property
def get_site_id(self) -> str:
"""
Get the site ID of the media item.
Returns:
int: The site ID of the media item.
"""
return f"{self.id}-{self.slug}"
class MediaManager:
def __init__(self):

0
api/__init__.py Normal file
View File

0
api/api/__init__.py Normal file
View File

16
api/api/asgi.py Normal file
View File

@ -0,0 +1,16 @@
"""
ASGI config for api project.
It exposes the ASGI callable as a module-level variable named ``application``.
For more information on this file, see
https://docs.djangoproject.com/en/4.2/howto/deployment/asgi/
"""
import os
from django.core.asgi import get_asgi_application
os.environ.setdefault("DJANGO_SETTINGS_MODULE", "api.settings")
application = get_asgi_application()

140
api/api/settings.py Normal file
View File

@ -0,0 +1,140 @@
"""
Django settings for api project.
Generated by 'django-admin startproject' using Django 4.2.7.
For more information on this file, see
https://docs.djangoproject.com/en/4.2/topics/settings/
For the full list of settings and their values, see
https://docs.djangoproject.com/en/4.2/ref/settings/
"""
from pathlib import Path
# Build paths inside the project like this: BASE_DIR / 'subdir'.
BASE_DIR = Path(__file__).resolve().parent.parent
# Quick-start development settings - unsuitable for production
# See https://docs.djangoproject.com/en/4.2/howto/deployment/checklist/
# SECURITY WARNING: keep the secret key used in production secret!
SECRET_KEY = "django-insecure-7u!h-#6b--%h8()19so$s+t9cjh5y1+ljnqum*@gm))0(a_qka"
# SECURITY WARNING: don't run with debug turned on in production!
DEBUG = True
ALLOWED_HOSTS = []
# Application definition
INSTALLED_APPS = [
"django.contrib.admin",
"django.contrib.auth",
"django.contrib.contenttypes",
"django.contrib.sessions",
"django.contrib.messages",
"django.contrib.staticfiles",
"corsheaders",
"endpoints",
"rest_framework",
]
MIDDLEWARE = [
"corsheaders.middleware.CorsMiddleware",
"django.middleware.security.SecurityMiddleware",
"django.contrib.sessions.middleware.SessionMiddleware",
"django.middleware.common.CommonMiddleware",
"django.middleware.csrf.CsrfViewMiddleware",
"django.contrib.auth.middleware.AuthenticationMiddleware",
"django.contrib.messages.middleware.MessageMiddleware",
"django.middleware.clickjacking.XFrameOptionsMiddleware",
]
ROOT_URLCONF = "api.urls"
TEMPLATES = [
{
"BACKEND": "django.template.backends.django.DjangoTemplates",
"DIRS": [],
"APP_DIRS": True,
"OPTIONS": {
"context_processors": [
"django.template.context_processors.debug",
"django.template.context_processors.request",
"django.contrib.auth.context_processors.auth",
"django.contrib.messages.context_processors.messages",
],
},
},
]
WSGI_APPLICATION = "api.wsgi.application"
# Database
# https://docs.djangoproject.com/en/4.2/ref/settings/#databases
DATABASES = {
"default": {
"ENGINE": "django.db.backends.sqlite3",
"NAME": BASE_DIR / "db.sqlite3",
}
}
# Password validation
# https://docs.djangoproject.com/en/4.2/ref/settings/#auth-password-validators
AUTH_PASSWORD_VALIDATORS = [
{
"NAME": "django.contrib.auth.password_validation.UserAttributeSimilarityValidator",
},
{
"NAME": "django.contrib.auth.password_validation.MinimumLengthValidator",
},
{
"NAME": "django.contrib.auth.password_validation.CommonPasswordValidator",
},
{
"NAME": "django.contrib.auth.password_validation.NumericPasswordValidator",
},
]
# Internationalization
# https://docs.djangoproject.com/en/4.2/topics/i18n/
LANGUAGE_CODE = "en-us"
TIME_ZONE = "UTC"
USE_I18N = True
USE_TZ = True
# Static files (CSS, JavaScript, Images)
# https://docs.djangoproject.com/en/4.2/howto/static-files/
STATIC_URL = "static/"
# Default primary key field type
# https://docs.djangoproject.com/en/4.2/ref/settings/#default-auto-field
DEFAULT_AUTO_FIELD = "django.db.models.BigAutoField"
CORS_ALLOWED_ORIGINS = [
"http://localhost:5173",
]
CORS_ALLOWED_METHODS = (
"DELETE",
"GET",
"OPTIONS",
"PATCH",
"POST",
"PUT",
)

24
api/api/urls.py Normal file
View File

@ -0,0 +1,24 @@
"""
URL configuration for api project.
The `urlpatterns` list routes URLs to views. For more information please see:
https://docs.djangoproject.com/en/4.2/topics/http/urls/
Examples:
Function views
1. Add an import: from my_app import views
2. Add a URL to urlpatterns: path('', views.home, name='home')
Class-based views
1. Add an import: from other_app.views import Home
2. Add a URL to urlpatterns: path('', Home.as_view(), name='home')
Including another URLconf
1. Import the include() function: from django.urls import include, path
2. Add a URL to urlpatterns: path('blog/', include('blog.urls'))
"""
from django.contrib import admin
from django.urls import path, include
urlpatterns = [
path("admin/", admin.site.urls),
path("api/", include("endpoints.urls")),
]

16
api/api/wsgi.py Normal file
View File

@ -0,0 +1,16 @@
"""
WSGI config for api project.
It exposes the WSGI callable as a module-level variable named ``application``.
For more information on this file, see
https://docs.djangoproject.com/en/4.2/howto/deployment/wsgi/
"""
import os
from django.core.wsgi import get_wsgi_application
os.environ.setdefault("DJANGO_SETTINGS_MODULE", "api.settings")
application = get_wsgi_application()

View File

3
api/endpoints/admin.py Normal file
View File

@ -0,0 +1,3 @@
from django.contrib import admin
# Register your models here.

6
api/endpoints/apps.py Normal file
View File

@ -0,0 +1,6 @@
from django.apps import AppConfig
class EndpointsConfig(AppConfig):
default_auto_field = "django.db.models.BigAutoField"
name = "endpoints"

View File

3
api/endpoints/models.py Normal file
View File

@ -0,0 +1,3 @@
from django.db import models
# Create your models here.

3
api/endpoints/tests.py Normal file
View File

@ -0,0 +1,3 @@
from django.test import TestCase
# Create your tests here.

10
api/endpoints/urls.py Normal file
View File

@ -0,0 +1,10 @@
from rest_framework import routers
from .views import SearchView#, DownloadView
router = routers.DefaultRouter()
router.register(r"search", SearchView, basename="search")
#router.register(r"download", DownloadView, basename="download")
urlpatterns = router.urls

241
api/endpoints/views.py Normal file
View File

@ -0,0 +1,241 @@
import json
import os
from django.http import StreamingHttpResponse
from rest_framework import viewsets
from rest_framework.decorators import action
from rest_framework.response import Response
from Src.Api.Animeunity import title_search as anime_search
from Src.Api.Animeunity.Core.Vix_player.player import VideoSource as anime_source
from Src.Api.Animeunity.site import media_search_manager as anime_media_manager
from Src.Api.Streamingcommunity import title_search as sc_search, get_version_and_domain
from Src.Api.Streamingcommunity.Core.Vix_player.player import VideoSource as film_video_source
from Src.Api.Streamingcommunity.site import media_search_manager as film_media_manager
class SearchView(viewsets.ViewSet):
def list(self, request):
self.search_query = request.query_params.get("search_terms")
self.type_search = request.query_params.get("type")
media_manager = anime_media_manager if self.type_search == "anime" else film_media_manager
media_manager.media_list = []
self.len_database = 0
if self.type_search == "film":
_, self.domain = get_version_and_domain()
self.len_database = sc_search(self.search_query, self.domain)
elif self.type_search == "anime":
self.len_database = anime_search(self.search_query)
media_list = media_manager.media_list
if self.len_database != 0:
data_to_return = []
for _, media in enumerate(media_list):
if self.type_search == "anime":
if media.type == "TV":
media.type = "TV_ANIME"
if media.type == "Movie":
media.type = "OVA"
data_to_return.append(media.to_dict)
return Response({"media": data_to_return})
return Response({"error": "No media found with that search query"})
@action(detail=False, methods=["get"])
def get_episodes_info(self, request):
self.media_id = request.query_params.get("media_id")
self.media_slug = request.query_params.get("media_slug")
self.type_media = request.query_params.get("type_media")
try:
match self.type_media:
case "TV":
def stream_episodes():
self.version, self.domain = get_version_and_domain()
video_source = film_video_source()
video_source.setup(
version=self.version,
domain=self.domain,
media_id=self.media_id,
series_name=self.media_slug
)
video_source.collect_info_seasons()
seasons_count = video_source.obj_title_manager.get_length()
episodes = {}
for i_season in range(1, seasons_count + 1):
video_source.obj_episode_manager.clear()
video_source.collect_title_season(i_season)
episodes_count = (
video_source.obj_episode_manager.get_length()
)
episodes[i_season] = {}
for i_episode in range(1, episodes_count + 1):
episode = video_source.obj_episode_manager.episodes[
i_episode - 1
]
episodes[i_season][i_episode] = episode.to_dict()
yield f'{json.dumps({"episodes": episodes})}\n\n'
response = StreamingHttpResponse(
stream_episodes(), content_type="text/event-stream"
)
return response
case "TV_ANIME":
def stream_episodes():
video_source = anime_source()
video_source.setup(
media_id = self.media_id,
series_name = self.media_slug
)
episoded_count = video_source.get_count_episodes()
for i in range(0, episoded_count):
episode_info = video_source.get_info_episode(i).to_dict()
episode_info["episode_id"] = i
episode_info["episode_total"] = episoded_count
print(f"Getting episode {i} of {episoded_count} info...")
yield f"{json.dumps(episode_info)}\n\n"
response = StreamingHttpResponse(
stream_episodes(), content_type="text/event-stream"
)
return response
except Exception as e:
return Response(
{
"error": "Error while getting episodes info",
"message": str(e),
}
)
return Response({"error": "No media found with that search query"})
@action(detail=False, methods=["get"])
def get_preview(self, request):
self.media_id = request.query_params.get("media_id")
self.media_slug = request.query_params.get("media_slug")
self.type_media = request.query_params.get("type_media")
try:
if self.type_media in ["TV", "MOVIE"]:
version, domain = get_version_and_domain()
video_source = film_video_source()
video_source.setup(media_id=self.media_id, version=version, domain=domain, series_name=self.media_slug)
video_source.get_preview()
return Response(video_source.obj_preview.to_dict())
if self.type_media in ["TV_ANIME", "OVA", "SPECIAL"]:
video_source = anime_source()
video_source.setup(media_id=self.media_id, series_name=self.media_slug)
video_source.get_preview()
return Response(video_source.obj_preview.to_dict())
except Exception as e:
return Response(
{
"error": "Error while getting preview info",
"message": str(e),
}
)
return Response({"error": "No media found with that search query"})
'''
class DownloadView(viewsets.ViewSet):
def create(self, request):
self.media_id = request.data.get("media_id")
self.media_slug = request.data.get("media_slug")
self.type_media = request.data.get("type_media").upper()
self.download_id = request.data.get("download_id")
self.tv_series_episode_id = request.data.get("tv_series_episode_id")
if self.type_media in ["TV", "MOVIE"]:
self.site_version, self.domain = get_version_and_domain()
response_dict = {"error": "No media found with that search query"}
try:
match self.type_media:
case "MOVIE":
download_film(self.media_id, self.media_slug, self.domain)
case "TV":
video_source = VideoSource()
video_source.set_url_base_name(STREAM_SITE_NAME)
video_source.set_version(self.site_version)
video_source.set_domain(self.domain)
video_source.set_series_name(self.media_slug)
video_source.set_media_id(self.media_id)
video_source.collect_info_seasons()
video_source.obj_episode_manager.clear()
video_source.collect_title_season(self.download_id)
episodes_count = video_source.obj_episode_manager.get_length()
for i_episode in range(1, episodes_count + 1):
episode_id = video_source.obj_episode_manager.episodes[
i_episode - 1
].id
# Define filename and path for the downloaded video
mp4_name = remove_special_characters(
f"{map_episode_title(self.media_slug,video_source.obj_episode_manager.episodes[i_episode - 1],self.download_id)}.mp4"
)
mp4_path = remove_special_characters(
os.path.join(
ROOT_PATH,
SERIES_FOLDER,
self.media_slug,
f"S{self.download_id}",
)
)
os.makedirs(mp4_path, exist_ok=True)
# Get iframe and content for the episode
video_source.get_iframe(episode_id)
video_source.get_content()
video_source.set_url_base_name(STREAM_SITE_NAME)
# Download the episode
obj_download = Downloader(
m3u8_playlist=video_source.get_playlist(),
key=video_source.get_key(),
output_filename=os.path.join(mp4_path, mp4_name),
)
obj_download.download_m3u8()
case "TV_ANIME":
episodes_downloader = EpisodeDownloader(
self.media_id, self.media_slug
)
episodes_downloader.download_episode(self.download_id)
case "OVA" | "SPECIAL":
anime_download_film(
id_film=self.media_id, title_name=self.media_slug
)
case _:
raise Exception("Type media not supported")
response_dict = {
"message": "Download done, it is saved in Video folder inside project root"
}
except Exception as e:
response_dict = {
"error": "Error while downloading the media",
"message": str(e),
}
return Response(response_dict)
'''

24
api/manage.py Executable file
View File

@ -0,0 +1,24 @@
#!/usr/bin/env python
"""Django's command-line utility for administrative tasks."""
import os
import sys
sys.path.append(os.path.abspath(os.path.join(os.path.dirname(__file__), "..")))
def main():
"""Run administrative tasks."""
os.environ.setdefault("DJANGO_SETTINGS_MODULE", "api.settings")
try:
from django.core.management import execute_from_command_line
except ImportError as exc:
raise ImportError(
"Couldn't import Django. Are you sure it's installed and "
"available on your PYTHONPATH environment variable? Did you "
"forget to activate a virtual environment?"
) from exc
execute_from_command_line(sys.argv)
if __name__ == "__main__":
main()

View File

@ -45,4 +45,4 @@
"animeunity": "to",
"altadefinizione": "food"
}
}
}

15
frontend/.eslintrc.cjs Normal file
View File

@ -0,0 +1,15 @@
/* eslint-env node */
require('@rushstack/eslint-patch/modern-module-resolution')
module.exports = {
root: true,
'extends': [
'plugin:vue/vue3-essential',
'eslint:recommended',
'@vue/eslint-config-typescript',
'@vue/eslint-config-prettier/skip-formatting'
],
parserOptions: {
ecmaVersion: 'latest'
}
}

30
frontend/.gitignore vendored Normal file
View File

@ -0,0 +1,30 @@
# Logs
logs
*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
pnpm-debug.log*
lerna-debug.log*
node_modules
.DS_Store
dist
dist-ssr
coverage
*.local
/cypress/videos/
/cypress/screenshots/
# Editor directories and files
.vscode/*
!.vscode/extensions.json
.idea
*.suo
*.ntvs*
*.njsproj
*.sln
*.sw?
*.tsbuildinfo

View File

@ -0,0 +1,8 @@
{
"$schema": "https://json.schemastore.org/prettierrc",
"semi": false,
"tabWidth": 2,
"singleQuote": true,
"printWidth": 100,
"trailingComma": "none"
}

7
frontend/.vscode/extensions.json vendored Normal file
View File

@ -0,0 +1,7 @@
{
"recommendations": [
"Vue.volar",
"dbaeumer.vscode-eslint",
"esbenp.prettier-vscode"
]
}

39
frontend/README.md Normal file
View File

@ -0,0 +1,39 @@
# frontend
This template should help get you started developing with Vue 3 in Vite.
## Recommended IDE Setup
[VSCode](https://code.visualstudio.com/) + [Volar](https://marketplace.visualstudio.com/items?itemName=Vue.volar) (and disable Vetur).
## Type Support for `.vue` Imports in TS
TypeScript cannot handle type information for `.vue` imports by default, so we replace the `tsc` CLI with `vue-tsc` for type checking. In editors, we need [Volar](https://marketplace.visualstudio.com/items?itemName=Vue.volar) to make the TypeScript language service aware of `.vue` types.
## Customize configuration
See [Vite Configuration Reference](https://vitejs.dev/config/).
## Project Setup
```sh
npm install
```
### Compile and Hot-Reload for Development
```sh
npm run dev
```
### Type-Check, Compile and Minify for Production
```sh
npm run build
```
### Lint with [ESLint](https://eslint.org/)
```sh
npm run lint
```

1
frontend/env.d.ts vendored Normal file
View File

@ -0,0 +1 @@
/// <reference types="vite/client" />

13
frontend/index.html Normal file
View File

@ -0,0 +1,13 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<link rel="icon" href="/favicon.ico">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Vite App</title>
</head>
<body>
<div id="app"></div>
<script type="module" src="/src/main.ts"></script>
</body>
</html>

3225
frontend/package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

36
frontend/package.json Normal file
View File

@ -0,0 +1,36 @@
{
"name": "frontend",
"version": "0.0.0",
"private": true,
"type": "module",
"scripts": {
"dev": "vite",
"build": "run-p type-check \"build-only {@}\" --",
"preview": "vite preview",
"build-only": "vite build",
"type-check": "vue-tsc --build --force",
"lint": "eslint . --ext .vue,.js,.jsx,.cjs,.mjs,.ts,.tsx,.cts,.mts --fix --ignore-path .gitignore",
"format": "prettier --write src/"
},
"dependencies": {
"axios": "^1.6.8",
"vue": "^3.4.21",
"vue-router": "^4.3.0"
},
"devDependencies": {
"@rushstack/eslint-patch": "^1.8.0",
"@tsconfig/node20": "^20.1.4",
"@types/node": "^20.12.5",
"@vitejs/plugin-vue": "^5.0.4",
"@vue/eslint-config-prettier": "^9.0.0",
"@vue/eslint-config-typescript": "^13.0.0",
"@vue/tsconfig": "^0.5.1",
"eslint": "^8.57.0",
"eslint-plugin-vue": "^9.23.0",
"npm-run-all2": "^6.1.2",
"prettier": "^3.2.5",
"typescript": "~5.4.0",
"vite": "^5.2.8",
"vue-tsc": "^2.0.11"
}
}

BIN
frontend/public/favicon.ico Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.2 KiB

36
frontend/src/App.vue Normal file
View File

@ -0,0 +1,36 @@
<script setup lang="ts">
import { RouterLink, RouterView } from 'vue-router'
</script>
<template>
<RouterView />
</template>
<style>
#app {
font-family: Avenir, Helvetica, Arial, sans-serif;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
margin-top: 60px;
}
button {
background-color: #42b883;
color: white;
border: none;
border-radius: 4px;
padding: 8px 16px;
font-size: 16px;
cursor: pointer;
transition: background-color 0.3s ease;
}
button:hover {
background-color: #3a9f74;
}
button:disabled {
background-color: #d3d3d3;
cursor: not-allowed;
}
</style>

81
frontend/src/api/api.ts Normal file
View File

@ -0,0 +1,81 @@
import axios from "axios";
import type { AxiosResponse } from "axios";
import type { DownloadResponse, MediaItemResponse } from "@/api/interfaces";
const BASE_URL = "http://localhost:8000/api";
const api = axios.create({
baseURL: BASE_URL,
});
async function get<T>(url: string): Promise<AxiosResponse<T>> {
return api.get(url);
}
async function post<T>(url: string, data: any): Promise<AxiosResponse<T>> {
return api.post(url, data);
}
export default function search(
query: string,
type: string
): Promise<AxiosResponse<MediaItemResponse>> {
return get(`/search?search_terms=${query}&type=${type}`);
}
export async function getEpisodesInfo(
mediaId: number,
mediaSlug: string,
mediaType: string
): Promise<Response> {
const url = `/search/get_episodes_info?media_id=${mediaId}&media_slug=${mediaSlug}&type_media=${mediaType}`;
return fetch(`${BASE_URL}${url}`, {
method: "GET",
headers: {
"Content-Type": "text/event-stream",
},
});
}
export async function getPreview(
mediaId: number,
mediaSlug: string,
mediaType: string
): Promise<AxiosResponse<any>> {
const url = `/search/get_preview?media_id=${mediaId}&media_slug=${mediaSlug}&type_media=${mediaType}`;
return get(url);
}
async function downloadMedia(
mediaId: number,
mediaSlug: string,
mediaType: string,
downloadId?: number,
tvSeriesEpisodeId?: number
): Promise<AxiosResponse<DownloadResponse>> {
const url = `/download/`;
const data = {
media_id: mediaId,
media_slug: mediaSlug,
type_media: mediaType,
download_id: downloadId,
tv_series_episode_id: tvSeriesEpisodeId,
};
return post(url, data);
}
export const downloadFilm = (mediaId: number, mediaSlug: string) =>
downloadMedia(mediaId, mediaSlug, "MOVIE");
export const downloadTvSeries = (
mediaId: number,
mediaSlug: string,
downloadId: number,
tvSeriesEpisodeId?: number
) => downloadMedia(mediaId, mediaSlug, "TV", downloadId, tvSeriesEpisodeId);
export const downloadAnimeFilm = (mediaId: number, mediaSlug: string) =>
downloadMedia(mediaId, mediaSlug, "OVA");
export const downloadAnimeSeries = (
mediaId: number,
mediaSlug: string,
downloadId: number
) => downloadMedia(mediaId, mediaSlug, "TV_ANIME", downloadId);

View File

@ -0,0 +1,64 @@
interface Image {
imageable_id: number;
imageable_type: string;
filename: string;
type: string;
original_url_field: string;
}
export interface MediaItem {
id: number;
slug: string;
name: string;
type: string;
score: string;
sub_ita: number;
last_air_date: string;
seasons_count: number;
images: Image[];
comment: string;
plot: string;
}
export interface MediaItemResponse {
media: MediaItem[];
}
export interface Episode {
id: number;
anime_id: number;
user_id: number | null;
number: string;
created_at: string;
link: string;
visite: number;
hidden: number;
public: number;
scws_id: number;
file_name: string;
tg_post: number;
episode_id: number;
episode_total: number;
name: string; // TV Show exclusive
plot: string; // TV Show exclusive
duration: number; // TV Show exclusive
season_id: number; // TV Show exclusive
created_by: any; // TV Show exclusive
updated_at: string; // TV Show exclusive
season_index: number; // TV Show exclusive
}
export interface Season {
[key: string]: {
[key: string]: Episode;
};
}
export interface SeasonResponse {
episodes: Season;
}
export interface DownloadResponse {
error: string;
message: string;
}

63
frontend/src/api/utils.ts Normal file
View File

@ -0,0 +1,63 @@
import {downloadAnimeFilm, downloadAnimeSeries, downloadFilm, downloadTvSeries} from "@/api/api";
import type {DownloadResponse, Episode, MediaItem, Season} from "@/api/interfaces";
export const handleTVDownload = async (tvShowEpisodes: any[], item: MediaItem) => {
alertDownload();
for (const season of tvShowEpisodes) {
const i = tvShowEpisodes.indexOf(season);
const res = (await downloadTvSeries(item.id, item.slug, i + 1)).data;
handleDownloadError(res);
}
};
export const handleTVEpisodesDownload = async (episodes: Episode[], item: MediaItem) => {
alertDownload();
for (const episode of episodes) {
const i = episodes.indexOf(episode);
const res = (await downloadTvSeries(item.id, item.slug, episode.season_index + 1, i)).data;
handleDownloadError(res);
}
}
export const handleMovieDownload = async (item: MediaItem) => {
alertDownload();
const res = (await downloadFilm(item.id, item.slug)).data;
handleDownloadError(res);
};
export const handleTVAnimeDownload = async (episodeCount: number, item: MediaItem) => {
alertDownload();
for (let i = 0; i < episodeCount; i++) {
const res = (await downloadAnimeSeries(item.id, item.slug, i)).data;
handleDownloadError(res);
}
};
export const handleTvAnimeEpisodesDownload = async (episodes: Episode[], item: MediaItem) => {
alertDownload();
for (const episode of episodes) {
const res = (await downloadAnimeSeries(item.id, item.slug, episode.episode_id)).data;
handleDownloadError(res);
}
}
export const handleOVADownload = async (item: MediaItem) => {
alertDownload();
const res = (await downloadAnimeFilm(item.id, item.slug)).data;
handleDownloadError(res);
};
const handleDownloadError = (res: DownloadResponse) => {
if (res.error) {
throw new Error(`${res.error} - ${res.message}`);
}
};
export const alertDownload = (message?: any) => {
if (message) {
alert(message)
return;
}
alert('Il downlaod è iniziato, il file sarà disponibile tra qualche minuto nella cartella \'Video\' del progetto...')
}

View File

@ -0,0 +1,86 @@
/* color palette from <https://github.com/vuejs/theme> */
:root {
--vt-c-white: #ffffff;
--vt-c-white-soft: #f8f8f8;
--vt-c-white-mute: #f2f2f2;
--vt-c-black: #181818;
--vt-c-black-soft: #222222;
--vt-c-black-mute: #282828;
--vt-c-indigo: #2c3e50;
--vt-c-divider-light-1: rgba(60, 60, 60, 0.29);
--vt-c-divider-light-2: rgba(60, 60, 60, 0.12);
--vt-c-divider-dark-1: rgba(84, 84, 84, 0.65);
--vt-c-divider-dark-2: rgba(84, 84, 84, 0.48);
--vt-c-text-light-1: var(--vt-c-indigo);
--vt-c-text-light-2: rgba(60, 60, 60, 0.66);
--vt-c-text-dark-1: var(--vt-c-white);
--vt-c-text-dark-2: rgba(235, 235, 235, 0.64);
}
/* semantic color variables for this project */
:root {
--color-background: var(--vt-c-white);
--color-background-soft: var(--vt-c-white-soft);
--color-background-mute: var(--vt-c-white-mute);
--color-border: var(--vt-c-divider-light-2);
--color-border-hover: var(--vt-c-divider-light-1);
--color-heading: var(--vt-c-text-light-1);
--color-text: var(--vt-c-text-light-1);
--section-gap: 160px;
}
@media (prefers-color-scheme: dark) {
:root {
--color-background: var(--vt-c-black);
--color-background-soft: var(--vt-c-black-soft);
--color-background-mute: var(--vt-c-black-mute);
--color-border: var(--vt-c-divider-dark-2);
--color-border-hover: var(--vt-c-divider-dark-1);
--color-heading: var(--vt-c-text-dark-1);
--color-text: var(--vt-c-text-dark-2);
}
}
*,
*::before,
*::after {
box-sizing: border-box;
margin: 0;
font-weight: normal;
}
body {
min-height: 100vh;
color: var(--color-text);
background: var(--color-background);
transition:
color 0.5s,
background-color 0.5s;
line-height: 1.6;
font-family:
Inter,
-apple-system,
BlinkMacSystemFont,
'Segoe UI',
Roboto,
Oxygen,
Ubuntu,
Cantarell,
'Fira Sans',
'Droid Sans',
'Helvetica Neue',
sans-serif;
font-size: 15px;
text-rendering: optimizeLegibility;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
}

View File

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 261.76 226.69"><path d="M161.096.001l-30.225 52.351L100.647.001H-.005l130.877 226.688L261.749.001z" fill="#41b883"/><path d="M161.096.001l-30.225 52.351L100.647.001H52.346l78.526 136.01L209.398.001z" fill="#34495e"/></svg>

After

Width:  |  Height:  |  Size: 276 B

View File

@ -0,0 +1,35 @@
@import './base.css';
#app {
max-width: 1280px;
margin: 0 auto;
padding: 2rem;
font-weight: normal;
}
a,
.green {
text-decoration: none;
color: hsla(160, 100%, 37%, 1);
transition: 0.4s;
padding: 3px;
}
@media (hover: hover) {
a:hover {
background-color: hsla(160, 100%, 37%, 0.2);
}
}
@media (min-width: 1024px) {
body {
display: flex;
place-items: center;
}
#app {
display: grid;
grid-template-columns: 1fr 1fr;
padding: 0 2rem;
}
}

View File

@ -0,0 +1,92 @@
<script setup lang="ts">
import { ref, onMounted } from 'vue';
import axios from 'axios';
import type {MediaItem} from "@/api/interfaces";
import {useRouter} from "vue-router";
import router from "@/router";
const props = defineProps<{
item: MediaItem;
mediaType: string;
}>();
const imageUrl = ref('');
const movieApiUrl = 'https://api.themoviedb.org/3/search/movie?api_key=15d2ea6d0dc1d476efbca3eba2b9bbfb&query=';
const animeApiUrl = 'https://kitsu.io/api/edge/anime?filter[text]=';
const navigateToDetails = () => {
router.push({ name: 'details', params: { item: JSON.stringify(props.item), imageUrl: imageUrl.value } });
};
onMounted(async () => {
imageUrl.value = "https://eapp.org/wp-content/uploads/2018/05/poster_placeholder.jpg";
if (props.item.images && props.item.images.length > 0) {
for (const image of props.item.images) {
if (image.type === "poster") {
imageUrl.value = "https://cdn.streamingcommunity.marketing/images/" + image.filename;
return;
}
}
}
if (props.mediaType == "film") {
try {
const response = await axios.get(movieApiUrl + props.item.name);
if (response.data.results.length === 0) {
return;
}
imageUrl.value = "http://image.tmdb.org/t/p/w500/" + response.data.results[0].poster_path;
} catch (error) {
console.error('Error fetching movie image:', error);
}
} else {
try {
const response = await axios.get(animeApiUrl + props.item.name);
if (response.data.data && response.data.data.length === 0) {
return;
}
imageUrl.value = response.data.data[0].attributes.posterImage.small;
} catch (error) {
console.error('Error fetching anime image:', error);
}
}
});
</script>
<template>
<div class="card" @click="navigateToDetails">
<img :src="imageUrl" :alt="item.name" class="card-image" />
<div class="card-title">
{{ item.name.slice(0, 25) + (item.name.length > 24 ? '...' : '') }}
</div>
</div>
</template>
<style scoped>
.card {
background-color: #313131;
border-radius: 4px;
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
overflow: hidden;
cursor: pointer;
}
.card:hover {
box-shadow: 0 4px 8px rgba(0, 0, 0, 0.2);
transform: translateY(-2px) scale(1.02);
transition: all 0.3s;
}
.card-image {
width: 100%;
height: auto;
object-fit: cover;
}
.card-title {
padding: 12px;
font-size: 16px;
font-weight: bold;
text-align: center;
}
</style>

View File

@ -0,0 +1,105 @@
<script setup lang="ts">
import { computed } from 'vue'
const props = defineProps({
modelValue: {
type: String,
required: true
}
})
const emit = defineEmits(['update:modelValue'])
const isAnimeSelected = computed(() => props.modelValue === 'anime')
const toggleOption = () => {
emit('update:modelValue', isAnimeSelected.value ? 'film' : 'anime')
}
</script>
<template>
<div class="switch-container">
<span :style="{'color':isAnimeSelected ? '':'green', 'font-weight': isAnimeSelected ? '':'bold'}" class="switch-label-left">Film/Serie TV</span>
<label class="switch">
<input type="checkbox" :checked="isAnimeSelected" @change="toggleOption">
<span class="slider round"></span>
</label>
<span :style="{'color':isAnimeSelected ? 'green':'', 'font-weight': isAnimeSelected ? 'bold':''}" class="switch-label-right">Anime</span>
</div>
</template>
<style scoped>
.switch-container {
display: flex;
align-items: center;
}
.switch-label-left,
.switch-label-right {
font-size: 14px;
color: #666;
}
.switch {
position: relative;
display: inline-block;
width: 60px;
height: 34px;
margin: 0 10px;
}
/* Hide default HTML checkbox */
.switch input {
opacity: 0;
width: 0;
height: 0;
}
/* The slider */
.slider {
position: absolute;
cursor: pointer;
top: 0;
left: 0;
right: 0;
bottom: 0;
background-color: #42b883;
-webkit-transition: .4s;
transition: .4s;
}
.slider:before {
position: absolute;
content: "";
height: 26px;
width: 26px;
left: 4px;
bottom: 4px;
background-color: white;
-webkit-transition: .4s;
transition: .4s;
}
input:checked + .slider {
background-color: #42b883;
}
input:focus + .slider {
box-shadow: 0 0 1px #42b883;
}
input:checked + .slider:before {
-webkit-transform: translateX(26px);
-ms-transform: translateX(26px);
transform: translateX(26px);
}
/* Rounded sliders */
.slider.round {
border-radius: 34px;
}
.slider.round:before {
border-radius: 50%;
}
</style>

11
frontend/src/main.ts Normal file
View File

@ -0,0 +1,11 @@
import './assets/main.css'
import { createApp } from 'vue'
import App from './App.vue'
import router from './router'
const app = createApp(App)
app.use(router)
app.mount('#app')

View File

@ -0,0 +1,30 @@
import { createRouter, createWebHistory } from 'vue-router'
import HomeView from '../views/HomeView.vue'
import Details from "../views/Details.vue";
const router = createRouter({
history: createWebHistory(import.meta.env.BASE_URL),
routes: [
{
path: '/',
name: 'home',
component: HomeView
},
{
path: '/details:item:imageUrl',
name: 'details',
component: Details,
props: route => {
let item;
try {
item = JSON.parse(<string>route.params.item);
} catch (error) {
item = {}; // default value
}
return { item: item, imageUrl: route.params.imageUrl };
},
}
]
})
export default router

View File

@ -0,0 +1,335 @@
<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)">
<p>Questo è un {{item.type}} (QUESTO TESTO E' A SCOPO DI TEST)</p>
</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>

View File

@ -0,0 +1,176 @@
<script setup lang="ts">
import search from "@/api/api";
import Toggle from "@/components/Toggle.vue";
import { ref, watch } from 'vue'
import type { MediaItem } from "@/api/interfaces";
import Card from "@/components/Card.vue";
import { onBeforeRouteLeave } from 'vue-router'
const selectedOption = ref('film')
const searchedTitle = ref('')
const searchResults = ref<MediaItem[]>([])
const loading = ref(false)
const storeSearchResults = () => {
localStorage.setItem('searchResults', JSON.stringify(searchResults.value))
localStorage.setItem('selectedOption', selectedOption.value)
}
const retrieveSearchResults = () => {
const storedResults = localStorage.getItem('searchResults')
try {
if (!storedResults) return
searchResults.value = JSON.parse(storedResults)
selectedOption.value = localStorage.getItem('selectedOption') || 'film'
} catch (e) {
}
}
watch(searchResults, storeSearchResults, { deep: true })
retrieveSearchResults()
function searchTitle() {
loading.value = true
search(searchedTitle.value, selectedOption.value).then((res) => {
searchResults.value = res.data.media
loading.value = false
}).catch((err) => {
console.log(err)
})
storeSearchResults()
}
</script>
<template>
<div class="search-container">
<div class="search-bar">
<input
v-model="searchedTitle"
v-on:keyup.enter="() => searchTitle()"
class="search-input"
type="text"
placeholder="Cerca un titolo..."
/>
<div class="toggle-button-container">
<Toggle style="margin-right: 30px" v-model="selectedOption" class="search-toggle"></Toggle>
<button @click="searchTitle">
<span v-if="!loading">Cerca</span>
<span v-else class="loader"></span>
</button>
</div>
</div>
</div>
<div v-if="searchResults && searchResults.length > 0" class="card-container">
<div v-for="result in searchResults" :key="result.id" class="card-item">
<Card :item="result" :media-type="selectedOption" />
</div>
</div>
<div v-else>
<p style="text-align: center; margin-top: 100px;">Nessun risultato trovato</p>
</div>
</template>
<style scoped>
.search-container {
position: fixed;
top: 0;
left: 0;
width: 100%;
background-color: #313131;
padding: 16px;
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
z-index: 1000;
}
.search-bar {
display: flex;
align-items: center;
background-color: #f5f5f5;
border-radius: 4px;
padding: 8px;
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
max-width: 800px;
margin: 0 auto;
}
.search-input {
flex-grow: 1;
border: none;
background-color: transparent;
outline: none;
font-size: 16px;
padding: 8px;
}
.toggle-button-container {
display: flex;
align-items: center;
}
.search-toggle {
margin: 0 8px;
}
.card-container {
display: grid;
grid-template-columns: repeat(4, 1fr);
gap: 20px;
padding: 100px 8% 20px;
max-width: 1200px;
margin: 0 auto;
}
.card-item {
width: 250px;
}
.loader {
width: 12px;
height: 12px;
border: 1px solid #FFF;
border-bottom-color: transparent;
border-radius: 50%;
display: inline-block;
box-sizing: border-box;
animation: rotation 1s linear infinite;
margin-left: 15px;
margin-right: 16px;
}
@keyframes rotation {
0% {
transform: rotate(0deg);
}
100% {
transform: rotate(360deg);
}
}
@media (max-width: 768px) {
.card-container {
grid-template-columns: repeat(2, 1fr);
padding: 120px 12% 20px;
}
}
@media (max-width: 480px) {
.search-bar {
flex-wrap: wrap;
}
.search-input {
flex-basis: 100%;
margin-bottom: 8px;
}
.toggle-button-container {
width: 100%;
justify-content: space-between;
}
.card-container {
grid-template-columns: 1fr;
}
}
</style>

View File

@ -0,0 +1,14 @@
{
"extends": "@vue/tsconfig/tsconfig.dom.json",
"include": ["env.d.ts", "src/**/*", "src/**/*.vue"],
"exclude": ["src/**/__tests__/*"],
"compilerOptions": {
"composite": true,
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo",
"baseUrl": ".",
"paths": {
"@/*": ["./src/*"]
}
}
}

11
frontend/tsconfig.json Normal file
View File

@ -0,0 +1,11 @@
{
"files": [],
"references": [
{
"path": "./tsconfig.node.json"
},
{
"path": "./tsconfig.app.json"
}
]
}

View File

@ -0,0 +1,19 @@
{
"extends": "@tsconfig/node20/tsconfig.json",
"include": [
"vite.config.*",
"vitest.config.*",
"cypress.config.*",
"nightwatch.conf.*",
"playwright.config.*"
],
"compilerOptions": {
"composite": true,
"noEmit": true,
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.node.tsbuildinfo",
"module": "ESNext",
"moduleResolution": "Bundler",
"types": ["node"]
}
}

16
frontend/vite.config.ts Normal file
View File

@ -0,0 +1,16 @@
import { fileURLToPath, URL } from 'node:url'
import { defineConfig } from 'vite'
import vue from '@vitejs/plugin-vue'
// https://vitejs.dev/config/
export default defineConfig({
plugins: [
vue(),
],
resolve: {
alias: {
'@': fileURLToPath(new URL('./src', import.meta.url))
}
}
})

29
kill_gui.sh Executable file
View File

@ -0,0 +1,29 @@
#!/bin/bash
# Trova i processi che utilizzano la porta 8000
PROCESSES=$(lsof -i :8000 | awk 'NR!=1 {print $2}')
# Termina i processi trovati
if [ -n "$PROCESSES" ]; then
echo "Terminating processes using port 8000:"
for PID in $PROCESSES; do
echo "Killing process with PID: $PID"
kill -9 $PID
done
else
echo "No processes found using port 8000"
fi
# Trova i processi che utilizzano la porta 5173
PROCESSES=$(lsof -i :5173 | awk 'NR!=1 {print $2}')
# Termina i processi trovati
if [ -n "$PROCESSES" ]; then
echo "Terminating processes using port 5173:"
for PID in $PROCESSES; do
echo "Killing process with PID: $PID"
kill -9 $PID
done
else
echo "No processes found using port 5173"
fi

View File

@ -1,4 +1,11 @@
bs4
tqdm
rich
unidecode
unidecode
ffmpeg-python
pycryptodome
m3u8
lxml
django==4.2.11
djangorestframework==3.15.1
django-cors-headers==4.3.1

23
start_gui.sh Executable file
View File

@ -0,0 +1,23 @@
#!/bin/bash
# Installa i pacchetti Python
echo "Installazione dei pacchetti Python..."
pip install -r requirements.txt
# Installa i pacchetti npm
echo "Installazione dei pacchetti npm..."
cd frontend
npm install
cd ..
# Avvia il backend Django
echo "Avvio del backend Django..."
python3.11 api/manage.py runserver &
# Avvia il frontend Vue.js con Vite
echo "Avvio del frontend Vue.js con Vite..."
cd frontend
npm run dev &
# Attendi l'esecuzione dei processi
wait