mirror of
https://github.com/Arrowar/StreamingCommunity.git
synced 2025-06-05 02:55:25 +00:00
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:
parent
7f6799d276
commit
306377a7be
13
.gitignore
vendored
13
.gitignore
vendored
@ -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
|
||||
|
@ -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:
|
||||
|
@ -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]
|
||||
}
|
||||
|
||||
|
@ -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):
|
||||
|
@ -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:
|
||||
|
@ -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]
|
||||
}
|
||||
|
||||
|
@ -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
0
api/__init__.py
Normal file
0
api/api/__init__.py
Normal file
0
api/api/__init__.py
Normal file
16
api/api/asgi.py
Normal file
16
api/api/asgi.py
Normal 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
140
api/api/settings.py
Normal 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
24
api/api/urls.py
Normal 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
16
api/api/wsgi.py
Normal 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()
|
0
api/endpoints/__init__.py
Normal file
0
api/endpoints/__init__.py
Normal file
3
api/endpoints/admin.py
Normal file
3
api/endpoints/admin.py
Normal file
@ -0,0 +1,3 @@
|
||||
from django.contrib import admin
|
||||
|
||||
# Register your models here.
|
6
api/endpoints/apps.py
Normal file
6
api/endpoints/apps.py
Normal file
@ -0,0 +1,6 @@
|
||||
from django.apps import AppConfig
|
||||
|
||||
|
||||
class EndpointsConfig(AppConfig):
|
||||
default_auto_field = "django.db.models.BigAutoField"
|
||||
name = "endpoints"
|
0
api/endpoints/migrations/__init__.py
Normal file
0
api/endpoints/migrations/__init__.py
Normal file
3
api/endpoints/models.py
Normal file
3
api/endpoints/models.py
Normal file
@ -0,0 +1,3 @@
|
||||
from django.db import models
|
||||
|
||||
# Create your models here.
|
3
api/endpoints/tests.py
Normal file
3
api/endpoints/tests.py
Normal file
@ -0,0 +1,3 @@
|
||||
from django.test import TestCase
|
||||
|
||||
# Create your tests here.
|
10
api/endpoints/urls.py
Normal file
10
api/endpoints/urls.py
Normal 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
241
api/endpoints/views.py
Normal 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
24
api/manage.py
Executable 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()
|
@ -45,4 +45,4 @@
|
||||
"animeunity": "to",
|
||||
"altadefinizione": "food"
|
||||
}
|
||||
}
|
||||
}
|
15
frontend/.eslintrc.cjs
Normal file
15
frontend/.eslintrc.cjs
Normal 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
30
frontend/.gitignore
vendored
Normal 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
|
8
frontend/.prettierrc.json
Normal file
8
frontend/.prettierrc.json
Normal 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
7
frontend/.vscode/extensions.json
vendored
Normal file
@ -0,0 +1,7 @@
|
||||
{
|
||||
"recommendations": [
|
||||
"Vue.volar",
|
||||
"dbaeumer.vscode-eslint",
|
||||
"esbenp.prettier-vscode"
|
||||
]
|
||||
}
|
39
frontend/README.md
Normal file
39
frontend/README.md
Normal 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
1
frontend/env.d.ts
vendored
Normal file
@ -0,0 +1 @@
|
||||
/// <reference types="vite/client" />
|
13
frontend/index.html
Normal file
13
frontend/index.html
Normal 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
3225
frontend/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
36
frontend/package.json
Normal file
36
frontend/package.json
Normal 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
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
36
frontend/src/App.vue
Normal 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
81
frontend/src/api/api.ts
Normal 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);
|
64
frontend/src/api/interfaces.ts
Normal file
64
frontend/src/api/interfaces.ts
Normal 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
63
frontend/src/api/utils.ts
Normal 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...')
|
||||
}
|
86
frontend/src/assets/base.css
Normal file
86
frontend/src/assets/base.css
Normal 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;
|
||||
}
|
1
frontend/src/assets/logo.svg
Normal file
1
frontend/src/assets/logo.svg
Normal 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 |
35
frontend/src/assets/main.css
Normal file
35
frontend/src/assets/main.css
Normal 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;
|
||||
}
|
||||
}
|
92
frontend/src/components/Card.vue
Normal file
92
frontend/src/components/Card.vue
Normal 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>
|
105
frontend/src/components/Toggle.vue
Normal file
105
frontend/src/components/Toggle.vue
Normal 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
11
frontend/src/main.ts
Normal 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')
|
30
frontend/src/router/index.ts
Normal file
30
frontend/src/router/index.ts
Normal 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
|
335
frontend/src/views/Details.vue
Normal file
335
frontend/src/views/Details.vue
Normal 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>
|
176
frontend/src/views/HomeView.vue
Normal file
176
frontend/src/views/HomeView.vue
Normal 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>
|
14
frontend/tsconfig.app.json
Normal file
14
frontend/tsconfig.app.json
Normal 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
11
frontend/tsconfig.json
Normal file
@ -0,0 +1,11 @@
|
||||
{
|
||||
"files": [],
|
||||
"references": [
|
||||
{
|
||||
"path": "./tsconfig.node.json"
|
||||
},
|
||||
{
|
||||
"path": "./tsconfig.app.json"
|
||||
}
|
||||
]
|
||||
}
|
19
frontend/tsconfig.node.json
Normal file
19
frontend/tsconfig.node.json
Normal 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
16
frontend/vite.config.ts
Normal 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
29
kill_gui.sh
Executable 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
|
@ -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
23
start_gui.sh
Executable 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
|
Loading…
x
Reference in New Issue
Block a user