diff --git a/.gitignore b/.gitignore index 27e7447..2fd6399 100644 --- a/.gitignore +++ b/.gitignore @@ -9,3 +9,4 @@ venv/** __pycache__ deploy/k8s/overlays deploy/k8s/base/secret.yaml +src/test_batch_limit.py diff --git a/backend_swagger.json b/backend_swagger.json new file mode 100644 index 0000000..9c6aec1 --- /dev/null +++ b/backend_swagger.json @@ -0,0 +1,1099 @@ +{ + "swagger": "2.0", + "basePath": "/podcast/v1", + "paths": { + "/bff/hp": { + "post": { + "responses": { + "200": { + "description": "Success" + } + }, + "operationId": "post_bff_hp", + "tags": [ + "bff" + ] + }, + "get": { + "responses": { + "200": { + "description": "Success", + "schema": { + "$ref": "#/definitions/API_BFF_HP" + } + } + }, + "operationId": "bff_hp", + "tags": [ + "bff" + ] + } + }, + "/bff/podcast/{podcast}/": { + "parameters": [ + { + "name": "podcast", + "in": "path", + "required": true, + "type": "string" + } + ], + "get": { + "responses": { + "200": { + "description": "Success", + "schema": { + "$ref": "#/definitions/API_BFF_PODCAST" + } + } + }, + "operationId": "bff_podcast", + "tags": [ + "bff" + ] + } + }, + "/bff/podcast/{podcast}/{episode}/": { + "parameters": [ + { + "name": "podcast", + "in": "path", + "required": true, + "type": "string" + }, + { + "name": "episode", + "in": "path", + "required": true, + "type": "string" + } + ], + "get": { + "responses": { + "200": { + "description": "Success", + "schema": { + "$ref": "#/definitions/API_BFF_EPISODE" + } + } + }, + "operationId": "bff_episode", + "tags": [ + "bff" + ] + } + }, + "/ms/podcast": { + "post": { + "responses": { + "200": { + "description": "Success" + } + }, + "operationId": "post_podcast", + "tags": [ + "ms" + ] + }, + "put": { + "responses": { + "200": { + "description": "Success" + } + }, + "operationId": "put_podcast", + "tags": [ + "ms" + ] + }, + "delete": { + "responses": { + "200": { + "description": "Success" + } + }, + "operationId": "delete_podcast", + "tags": [ + "ms" + ] + }, + "get": { + "responses": { + "200": { + "description": "Success" + } + }, + "operationId": "get_podcast", + "tags": [ + "ms" + ] + } + }, + "/podcast/": { + "get": { + "responses": { + "200": { + "description": "Success", + "schema": { + "$ref": "#/definitions/API_PODCAST" + } + } + }, + "operationId": "podcast", + "parameters": [ + { + "name": "pg", + "in": "query", + "type": "integer", + "minimum": 0, + "exclusiveMinimum": true, + "description": "pagina", + "default": 1 + }, + { + "name": "hits", + "in": "query", + "type": "integer", + "minimum": 0, + "exclusiveMinimum": true, + "description": "numero risultati per pagina", + "default": 20 + } + ], + "tags": [ + "podcast" + ] + } + }, + "/podcast/check": { + "get": { + "responses": { + "200": { + "description": "Success", + "schema": { + "$ref": "#/definitions/API_PODCAST_EPISODE" + } + } + }, + "operationId": "check", + "parameters": [ + { + "name": "episode", + "in": "query", + "type": "string", + "required": true, + "description": "il-senza-glutine-e-meglio-solo-per-alcune-persone" + } + ], + "tags": [ + "podcast" + ] + } + }, + "/podcast/episodes": { + "get": { + "responses": { + "200": { + "description": "Success", + "schema": { + "$ref": "#/definitions/API_PODCAST_EPISODE" + } + } + }, + "operationId": "episodes", + "parameters": [ + { + "name": "ids", + "in": "query", + "type": "string", + "required": true, + "description": "Comma-separated list of integers, e.g., \"1,2,3\"" + } + ], + "tags": [ + "podcast" + ] + } + }, + "/podcast/{podcast}/": { + "parameters": [ + { + "name": "podcast", + "in": "path", + "required": true, + "type": "string" + } + ], + "get": { + "responses": { + "200": { + "description": "Success", + "schema": { + "$ref": "#/definitions/API_PODCAST_EPISODES" + } + } + }, + "operationId": "podcast", + "parameters": [ + { + "name": "pg", + "in": "query", + "type": "integer", + "minimum": 0, + "exclusiveMinimum": true, + "description": "pagina", + "default": 1 + }, + { + "name": "hits", + "in": "query", + "type": "integer", + "minimum": 0, + "exclusiveMinimum": true, + "description": "numero risultati per pagina", + "default": 20 + } + ], + "tags": [ + "podcast" + ] + } + }, + "/podcast/{podcast}/{episode}/": { + "parameters": [ + { + "name": "podcast", + "in": "path", + "required": true, + "type": "string" + }, + { + "name": "episode", + "in": "path", + "required": true, + "type": "string" + } + ], + "get": { + "responses": { + "200": { + "description": "Success", + "schema": { + "$ref": "#/definitions/API_PODCAST_EPISODE" + } + } + }, + "operationId": "podcast", + "parameters": [ + { + "name": "pg", + "in": "query", + "type": "integer", + "minimum": 0, + "exclusiveMinimum": true, + "description": "pagina", + "default": 1 + }, + { + "name": "hits", + "in": "query", + "type": "integer", + "minimum": 0, + "exclusiveMinimum": true, + "description": "numero risultati per pagina", + "default": 20 + } + ], + "tags": [ + "podcast" + ] + } + }, + "/sys/ping": { + "get": { + "responses": { + "200": { + "description": "Success" + } + }, + "operationId": "get_ping", + "tags": [ + "sys" + ] + } + } + }, + "info": { + "title": "ApiApp2024 v1", + "version": "1.0", + "description": "ApiApp2024 version 1.0" + }, + "produces": [ + "application/json" + ], + "consumes": [ + "application/json" + ], + "tags": [ + { + "name": "sys", + "description": "System" + }, + { + "name": "podcast", + "description": "Podcast" + }, + { + "name": "bff", + "description": "Backend For Frontend" + }, + { + "name": "ms", + "description": "MicroServices" + } + ], + "definitions": { + "API_PODCAST": { + "properties": { + "head": { + "$ref": "#/definitions/model_generic_podcast_response_head" + }, + "data": { + "type": "array", + "items": { + "$ref": "#/definitions/local_model_light_schema_podcast" + } + } + }, + "type": "object" + }, + "model_generic_podcast_response_head": { + "required": [ + "data" + ], + "properties": { + "exec_time": { + "type": "number" + }, + "status": { + "type": "integer" + }, + "data": { + "$ref": "#/definitions/model_generic_response_head_data_field" + } + }, + "type": "object" + }, + "model_generic_response_head_data_field": { + "properties": { + "total": { + "type": "integer" + }, + "pg": { + "type": "integer" + }, + "hits": { + "type": "integer" + } + }, + "type": "object" + }, + "local_model_light_schema_podcast": { + "required": [ + "author", + "description", + "id", + "meta" + ], + "properties": { + "id": { + "type": "integer", + "readOnly": true + }, + "author": { + "type": "string" + }, + "description": { + "type": "string" + }, + "title": { + "type": "string" + }, + "image": { + "type": "string" + }, + "image_web": { + "type": "string" + }, + "object": { + "type": "string" + }, + "count": { + "type": "integer" + }, + "slug": { + "type": "string" + }, + "url": { + "type": "string" + }, + "meta": { + "$ref": "#/definitions/model_model_podcast_meta_nested" + }, + "access_level": { + "type": "string" + } + }, + "type": "object" + }, + "model_model_podcast_meta_nested": { + "properties": { + "gift": { + "type": "integer" + }, + "gift_all": { + "type": "integer" + }, + "pushnotification": { + "type": "integer" + }, + "chronological": { + "type": "integer" + }, + "order": { + "type": "integer" + }, + "robot": { + "type": "string" + }, + "sponsored": { + "type": "integer" + }, + "cyclicality": { + "type": "string" + }, + "evidenza": { + "type": "string" + }, + "cyclicalitytype": { + "type": "string" + }, + "background_color": { + "type": "string", + "default": "#FFFFFF" + } + }, + "type": "object" + }, + "API_PODCAST_EPISODES": { + "properties": { + "head": { + "$ref": "#/definitions/model_generic_podcast_response_head" + }, + "data": { + "type": "array", + "items": { + "$ref": "#/definitions/local_model_light_schema_episode" + } + } + }, + "type": "object" + }, + "local_model_light_schema_episode": { + "required": [ + "author", + "gift", + "id", + "image", + "image_web", + "meta", + "parent", + "summary" + ], + "properties": { + "id": { + "type": "integer", + "readOnly": true + }, + "author": { + "type": "string" + }, + "title": { + "type": "string" + }, + "_click": { + "type": "string", + "default": "episode" + }, + "summary": { + "type": "string" + }, + "content_html": { + "type": "string" + }, + "image": { + "type": "string" + }, + "image_web": { + "type": "string" + }, + "object": { + "type": "string" + }, + "milliseconds": { + "type": "integer" + }, + "minutes": { + "type": "integer" + }, + "special": { + "type": "integer" + }, + "share_url": { + "type": "string" + }, + "slug": { + "type": "string" + }, + "full_slug": { + "type": "string" + }, + "url": { + "type": "string" + }, + "episode_raw_url": { + "type": "string" + }, + "meta": { + "$ref": "#/definitions/model_model_podcast_meta_nested" + }, + "access_level": { + "type": "string" + }, + "timestamp": { + "type": "integer" + }, + "date": { + "type": "string", + "format": "date-time" + }, + "date_string": { + "type": "string" + }, + "gift": { + "type": "boolean" + }, + "parent": { + "$ref": "#/definitions/model_light_schema_podcast" + }, + "queue_list": { + "type": "string" + } + }, + "type": "object" + }, + "model_light_schema_podcast": { + "required": [ + "author", + "description", + "id", + "meta" + ], + "properties": { + "id": { + "type": "integer", + "readOnly": true + }, + "author": { + "type": "string" + }, + "description": { + "type": "string" + }, + "title": { + "type": "string" + }, + "image": { + "type": "string" + }, + "image_web": { + "type": "string" + }, + "object": { + "type": "string" + }, + "count": { + "type": "integer" + }, + "slug": { + "type": "string" + }, + "url": { + "type": "string" + }, + "meta": { + "$ref": "#/definitions/model_model_podcast_meta_nested" + }, + "access_level": { + "type": "string" + } + }, + "type": "object" + }, + "API_PODCAST_EPISODE": { + "properties": { + "head": { + "$ref": "#/definitions/model_generic_podcast_response_head" + }, + "data": { + "$ref": "#/definitions/local_model_full_schema_episode" + } + }, + "type": "object" + }, + "local_model_full_schema_episode": { + "required": [ + "author", + "id", + "image", + "image_web", + "meta", + "parent", + "summary" + ], + "properties": { + "id": { + "type": "integer", + "readOnly": true + }, + "author": { + "type": "string" + }, + "title": { + "type": "string" + }, + "summary": { + "type": "string" + }, + "content_html": { + "type": "string" + }, + "image": { + "type": "string" + }, + "image_web": { + "type": "string" + }, + "object": { + "type": "string" + }, + "milliseconds": { + "type": "integer" + }, + "minutes": { + "type": "integer" + }, + "special": { + "type": "integer" + }, + "share_url": { + "type": "string" + }, + "slug": { + "type": "string" + }, + "full_slug": { + "type": "string" + }, + "url": { + "type": "string" + }, + "episode_raw_url": { + "type": "string" + }, + "meta": { + "$ref": "#/definitions/model_model_podcast_meta_nested" + }, + "access_level": { + "type": "string" + }, + "timestamp": { + "type": "integer" + }, + "date": { + "type": "string", + "format": "date-time" + }, + "date_string": { + "type": "string" + }, + "gift": { + "type": "string" + }, + "parent": { + "$ref": "#/definitions/model_light_schema_podcast" + }, + "queue_list": { + "type": "array", + "items": { + "type": "integer" + } + } + }, + "type": "object" + }, + "API_BFF_HP": { + "properties": { + "head": { + "$ref": "#/definitions/model_response_head" + }, + "data": { + "type": "array", + "items": { + "$ref": "#/definitions/model_bff_components_response" + } + } + }, + "type": "object" + }, + "model_response_head": { + "properties": { + "status": { + "type": "integer" + }, + "exec_time": { + "type": "number" + }, + "debug_extra": { + "type": "object" + } + }, + "type": "object" + }, + "model_bff_components_response": { + "properties": { + "key": { + "type": "string" + }, + "head": { + "$ref": "#/definitions/model_bff_components_response_head" + }, + "meta": { + "$ref": "#/definitions/model_bff_components_response_meta" + }, + "data": { + "type": "array", + "items": { + "$ref": "#/definitions/local_model_light_schema_episode" + } + } + }, + "type": "object" + }, + "model_bff_components_response_head": { + "properties": { + "total": { + "type": "integer" + }, + "status": { + "type": "integer" + }, + "params": { + "type": "object" + } + }, + "type": "object" + }, + "model_bff_components_response_meta": { + "properties": { + "label": { + "type": "string" + }, + "show_label": { + "type": "boolean" + }, + "link_to": { + "type": "string" + }, + "presentation": { + "type": "string" + }, + "extra": { + "type": "object" + } + }, + "type": "object" + }, + "API_BFF_PODCAST": { + "properties": { + "head": { + "$ref": "#/definitions/model_response_head" + }, + "data": { + "$ref": "#/definitions/model_bff_podcast_components" + } + }, + "type": "object" + }, + "model_bff_podcast_components": { + "properties": { + "podcast": { + "$ref": "#/definitions/model_components_bff_podcast" + }, + "episodes": { + "$ref": "#/definitions/model_components_bff_episode_wop" + }, + "articoli": { + "$ref": "#/definitions/model_components_bff_content" + } + }, + "type": "object" + }, + "model_components_bff_podcast": { + "properties": { + "head": { + "$ref": "#/definitions/local_model_bff_components_response_head" + }, + "meta": { + "$ref": "#/definitions/local_model_bff_components_response_meta" + }, + "data": { + "$ref": "#/definitions/local_model_light_schema_podcast" + } + }, + "type": "object" + }, + "local_model_bff_components_response_head": { + "properties": { + "status": { + "type": "integer" + }, + "exec_time": { + "type": "number" + }, + "debug_extra": { + "type": "object" + } + }, + "type": "object" + }, + "local_model_bff_components_response_meta": { + "properties": { + "status": { + "type": "integer" + }, + "exec_time": { + "type": "number" + }, + "debug_extra": { + "type": "object" + } + }, + "type": "object" + }, + "model_components_bff_episode_wop": { + "properties": { + "head": { + "$ref": "#/definitions/local_model_bff_components_response_head" + }, + "meta": { + "$ref": "#/definitions/local_model_bff_components_response_meta" + }, + "data": { + "type": "array", + "items": { + "$ref": "#/definitions/local_model_light_schema_episode_without_parent" + } + } + }, + "type": "object" + }, + "local_model_light_schema_episode_without_parent": { + "required": [ + "author", + "id", + "image", + "image_web", + "meta", + "summary" + ], + "properties": { + "id": { + "type": "integer", + "readOnly": true + }, + "author": { + "type": "string" + }, + "title": { + "type": "string" + }, + "summary": { + "type": "string" + }, + "content_html": { + "type": "string" + }, + "image": { + "type": "string" + }, + "image_web": { + "type": "string" + }, + "object": { + "type": "string" + }, + "milliseconds": { + "type": "integer" + }, + "minutes": { + "type": "integer" + }, + "special": { + "type": "integer" + }, + "share_url": { + "type": "string" + }, + "slug": { + "type": "string" + }, + "full_slug": { + "type": "string" + }, + "url": { + "type": "string" + }, + "episode_raw_url": { + "type": "string" + }, + "meta": { + "$ref": "#/definitions/model_model_podcast_meta_nested" + }, + "access_level": { + "type": "string" + }, + "timestamp": { + "type": "integer" + }, + "date": { + "type": "string", + "format": "date-time" + }, + "date_string": { + "type": "string" + }, + "gift": { + "type": "string" + } + }, + "type": "object" + }, + "model_components_bff_content": { + "properties": { + "head": { + "$ref": "#/definitions/local_model_bff_components_response_head" + }, + "meta": { + "$ref": "#/definitions/local_model_bff_components_response_meta" + }, + "data": { + "type": "array", + "items": { + "$ref": "#/definitions/local_model_light_schema_content" + } + } + }, + "type": "object" + }, + "local_model_light_schema_content": { + "required": [ + "id", + "title" + ], + "properties": { + "id": { + "type": "integer", + "readOnly": true + }, + "title": { + "type": "string" + }, + "link": { + "type": "string" + }, + "image": { + "type": "string" + }, + "timestamp": { + "type": "string" + }, + "access_level": { + "type": "string" + } + }, + "type": "object" + }, + "API_BFF_EPISODE": { + "properties": { + "head": { + "$ref": "#/definitions/model_response_head" + }, + "data": { + "$ref": "#/definitions/model_response_bff_episode" + } + }, + "type": "object" + }, + "model_response_bff_episode": { + "properties": { + "episode": { + "$ref": "#/definitions/local_model_bff_components_response_episode" + }, + "related": { + "$ref": "#/definitions/local_model_bff_components_response_related" + } + }, + "type": "object" + }, + "local_model_bff_components_response_episode": { + "properties": { + "head": { + "$ref": "#/definitions/local_model_bff_components_response_head" + }, + "meta": { + "$ref": "#/definitions/local_model_bff_components_response_meta" + }, + "data": { + "$ref": "#/definitions/local_model_light_schema_episode" + } + }, + "type": "object" + }, + "local_model_bff_components_response_related": { + "properties": { + "head": { + "$ref": "#/definitions/local_model_bff_components_response_head" + }, + "meta": { + "$ref": "#/definitions/local_model_bff_components_response_meta" + }, + "data": { + "type": "array", + "items": { + "$ref": "#/definitions/local_model_light_schema_episode" + } + } + }, + "type": "object" + } + }, + "responses": { + "ParseError": { + "description": "When a mask can't be parsed" + }, + "MaskError": { + "description": "When any error occurs on mask" + } + } +} diff --git a/deploy/k8s/base/deployment.yaml b/deploy/k8s/base/deployment.yaml index 31b087f..0ef182e 100644 --- a/deploy/k8s/base/deployment.yaml +++ b/deploy/k8s/base/deployment.yaml @@ -30,6 +30,17 @@ spec: # capabilities: # drop: # - ALL + env: + - name: EMAIL + valueFrom: + secretKeyRef: + name: ilpost-api-credentials + key: EMAIL + - name: PASSWORD + valueFrom: + secretKeyRef: + name: ilpost-api-credentials + key: PASSWORD ports: - containerPort: 5000 volumeMounts: @@ -72,17 +83,6 @@ spec: envFrom: - configMapRef: name: ilpostapi-config - env: - - name: EMAIL - valueFrom: - secretKeyRef: - name: ilpostapi-credentials - key: EMAIL - - name: PASSWORD - valueFrom: - secretKeyRef: - name: ilpostapi-credentials - key: PASSWORD volumes: - name: data - name: tmp diff --git a/skaffold.yaml b/skaffold.yaml index 1aaf6db..88b7a83 100644 --- a/skaffold.yaml +++ b/skaffold.yaml @@ -9,15 +9,25 @@ build: dockerfile: Dockerfile sync: infer: - - 'src/database/__init__.py' - - 'src/**/*.py' - - 'src/**/*.html' - - 'src/**/*.css' - - 'src/**/*.js' - - 'src/**/*.json' - - 'src/**/*.txt' - - 'src/**/*.yaml' - - 'src/**/*.yml' + - 'src/database/__init__.py' + - 'src/**/*.py' + - 'src/**/*.html' + - 'src/**/*.css' + - 'src/**/*.js' + - 'src/**/*.json' + - 'src/**/*.txt' + - 'src/**/*.yaml' + - 'src/**/*.yml' +manifests: + kustomize: + paths: + - deploy/k8s/base +portForward: +- resourceType: deployment + resourceName: ilpostapi + port: 5000 + address: 0.0.0.0 + localPort: 5000 profiles: - name: dev manifests: @@ -26,12 +36,6 @@ profiles: - deploy/k8s/overlays/dev deploy: kubectl: {} - portForward: - - resourceType: deployment - resourceName: dev-ilpostapi - port: 5000 - address: 0.0.0.0 - localPort: 5000 - name: prod manifests: kustomize: diff --git a/src/database/operations.py b/src/database/operations.py index 54d01d2..2e58e01 100644 --- a/src/database/operations.py +++ b/src/database/operations.py @@ -70,7 +70,11 @@ async def get_podcast_episodes( Tuple[List[Episode], bool]: Lista degli episodi e flag che indica se serve un aggiornamento """ # Prima proviamo a cercare per ID interno - stmt = select(Podcast).where(Podcast.id == podcast_id).options(selectinload(Podcast.episodes)) + stmt = ( + select(Podcast) + .where(Podcast.id == podcast_id) + .options(selectinload(Podcast.episodes)) + ) result = await db.execute(stmt) podcast = result.scalar_one_or_none() @@ -120,25 +124,29 @@ async def save_episodes( except (ValueError, KeyError): publication_date = datetime.utcnow() + # Otteniamo la descrizione dal content_html o dalla description + description = episode_data.get("content_html", "") or episode_data.get( + "description", "" + ) + if not episode: # Creiamo un nuovo episodio episode = Episode( ilpost_id=ilpost_id, podcast=podcast, title=episode_data.get("title", ""), - description=episode_data.get("description", ""), - description_verified=False, # La descrizione non è verificata finché non abbiamo i dettagli completi + description=description, + description_verified=True, # La descrizione è verificata perché viene dal batch audio_url=episode_data.get("episode_raw_url", ""), publication_date=publication_date, duration=episode_data.get("milliseconds", 0) // 1000, ) db.add(episode) else: - # Aggiorniamo l'episodio esistente ma manteniamo lo stato di verifica della descrizione + # Aggiorniamo l'episodio esistente episode.title = episode_data.get("title", episode.title) - if not episode.description_verified: - # Aggiorniamo la descrizione solo se non è stata già verificata - episode.description = episode_data.get("description", episode.description) + episode.description = description + episode.description_verified = True # Aggiorniamo lo stato di verifica episode.audio_url = episode_data.get("episode_raw_url", episode.audio_url) episode.publication_date = publication_date episode.duration = episode_data.get("milliseconds", 0) // 1000 @@ -146,7 +154,9 @@ async def save_episodes( await db.commit() -async def get_podcast_by_ilpost_id(db: AsyncSession, ilpost_id: str) -> Optional[Podcast]: +async def get_podcast_by_ilpost_id( + db: AsyncSession, ilpost_id: str +) -> Optional[Podcast]: """ Recupera un podcast dal database usando l'ID de Il Post. @@ -162,7 +172,9 @@ async def get_podcast_by_ilpost_id(db: AsyncSession, ilpost_id: str) -> Optional return result.scalar_one_or_none() -async def get_episode_by_ilpost_id(db: AsyncSession, ilpost_id: str) -> Optional[Episode]: +async def get_episode_by_ilpost_id( + db: AsyncSession, ilpost_id: str +) -> Optional[Episode]: """ Recupera un episodio dal database usando l'ID de Il Post. diff --git a/src/main.py b/src/main.py index 595530a..b5603f5 100644 --- a/src/main.py +++ b/src/main.py @@ -77,8 +77,8 @@ async def lifespan(app: FastAPI): raise ValueError("EMAIL and PASSWORD are required but not set properly") # Base URLs for the external APIs -PODCAST_API_BASE_URL = "https://api-prod.ilpost.it./podcast/v1/podcast" -API_AUTH_LOGIN = "https://api-prod.ilpost.it./user/v1/auth/login" +PODCAST_API_BASE_URL = "https://api-prod.ilpost.it/podcast/v1/podcast" +API_AUTH_LOGIN = "https://api-prod.ilpost.it/user/v1/auth/login" # Token caching configuration TOKEN_CACHE_TTL = 2 * 60 * 60 # 2 hours in seconds @@ -227,6 +227,48 @@ def format_date_time(isodate): } ) +# Aggiungiamo le funzioni ai filtri di Jinja2 +templates.env.filters.update( + { + "formatDateMain": format_date_main, + "formatDateYear": format_date_year, + "formatDateTime": format_date_time, + } +) + + +# Funzione per l'escape di stringhe JavaScript +def escapejs(text): + """Escape di stringhe per JavaScript.""" + if not text: + return "" + text = str(text) + chars = { + "\\": "\\\\", + '"': '\\"', + "'": "\\'", + "\n": "\\n", + "\r": "\\r", + "\t": "\\t", + "\f": "\\f", + "\b": "\\b", + "<": "\\u003C", + ">": "\\u003E", + "&": "\\u0026", + } + return "".join(chars.get(c, c) for c in text) + + +# Aggiungiamo le funzioni ai filtri di Jinja2 +templates.env.filters.update( + { + "formatDateMain": format_date_main, + "formatDateYear": format_date_year, + "formatDateTime": format_date_time, + "escapejs": escapejs, # Aggiungiamo il nuovo filtro + } +) + # --- Helper Functions --- BASE_URL = os.getenv("BASE_URL", "http://localhost:5000") @@ -237,7 +279,9 @@ def get_cached_token(): """Ottiene un token di autenticazione (cached).""" logger.info("🔄 Cache MISS - Richiesta nuovo token di autenticazione") try: - response = http_client.post(API_AUTH_LOGIN, data={"username": EMAIL, "password": PASSWORD}) + response = http_client.post( + API_AUTH_LOGIN, data={"username": EMAIL, "password": PASSWORD} + ) response.raise_for_status() token_data = response.json() token = token_data["data"]["data"]["token"] @@ -269,7 +313,7 @@ async def make_api_request(url: str, headers: Optional[Dict] = None) -> Dict: response = await client.get(url, headers=headers) if response.status_code == 429: # Too Many Requests logger.warning("Rate limit raggiunto, attendo prima di riprovare") - await asyncio.sleep(60) # Attendi 1 minuto + await asyncio.sleep(10) # Attendi 1 minuto return await make_api_request(url, headers) response.raise_for_status() data = response.json() @@ -301,9 +345,14 @@ async def fetch_podcasts(page: int = 1, hits: int = 10000): return cached_data token = get_cached_token() + # Il Post API richiede sia il token di autenticazione che una Apikey statica headers = {"Apikey": "testapikey", "Token": token} - logger.info(f"📻 Recupero podcast dalla pagina {page} con {hits} risultati per pagina") - data = await make_api_request(f"{PODCAST_API_BASE_URL}/?pg={page}&hits={hits}", headers=headers) + logger.info( + f"📻 Recupero podcast dalla pagina {page} con {hits} risultati per pagina" + ) + data = await make_api_request( + f"{PODCAST_API_BASE_URL}/?pg={page}&hits={hits}", headers=headers + ) # Salva nella cache PODCASTS_CACHE[cache_key] = (data, current_time) @@ -314,6 +363,7 @@ async def fetch_podcasts(page: int = 1, hits: int = 10000): async def fetch_episodes(podcast_id: int, page: int = 1, hits: int = 1): """Recupera gli episodi di un podcast.""" token = get_cached_token() + # Il Post API richiede sia il token di autenticazione che una Apikey statica headers = {"Apikey": "testapikey", "Token": token} logger.info(f"🎧 Recupero episodi per il podcast {podcast_id} dalla pagina {page}") return await make_api_request( @@ -321,42 +371,109 @@ async def fetch_episodes(podcast_id: int, page: int = 1, hits: int = 1): ) -async def fetch_all_episodes(podcast_id: int, batch_size: int = 10000): - """Recupera tutti gli episodi di un podcast.""" +async def fetch_episodes_batch(episode_ids: list[int]) -> Dict: + """Recupera un gruppo di episodi in una singola chiamata. + + Args: + episode_ids: Lista di ID degli episodi da recuperare + Returns: + Dict con i dati degli episodi + """ + if not episode_ids: + return {"data": []} + + token = get_cached_token() + # Il Post API richiede sia il token di autenticazione che una Apikey statica + headers = {"Apikey": "testapikey", "Token": token} + + # Convertiamo gli ID in stringa separata da virgole + ids_str = ",".join(map(str, episode_ids)) + logger.info(f"🎧 Recupero batch di {len(episode_ids)} episodi") + + return await make_api_request( + f"{PODCAST_API_BASE_URL}/episodes?ids={ids_str}", headers=headers + ) + + +async def fetch_all_episodes(podcast_id: int, batch_size: int = 500): + """Recupera tutti gli episodi di un podcast usando la paginazione ottimizzata. + + Args: + podcast_id: ID del podcast + batch_size: Numero di episodi per pagina (default: 500 per performance ottimale) + """ current_time = datetime.now().timestamp() # Controlla se abbiamo una versione cached valida if podcast_id in EPISODES_LIST_CACHE: cached_data, cache_time = EPISODES_LIST_CACHE[podcast_id] if current_time - cache_time < CACHE_TTL: - logger.info(f"🎯 Cache HIT - Lista episodi del podcast {podcast_id} trovata in cache") + logger.info( + f"🎯 Cache HIT - Lista episodi del podcast {podcast_id} trovata in cache" + ) return cached_data logger.info(f"📥 Inizio recupero di tutti gli episodi per il podcast {podcast_id}") - page = 1 + + # Prima richiesta per ottenere il totale degli episodi + initial_data = await fetch_episodes(podcast_id, page=1, hits=1) + total_episodes = initial_data["head"]["data"]["total"] + logger.info(f"📊 Totale episodi da recuperare: {total_episodes}") + + # Se abbiamo meno di 500 episodi, li prendiamo tutti in una volta + if total_episodes <= batch_size: + try: + logger.info( + f"📦 Recupero tutti gli {total_episodes} episodi in una singola richiesta" + ) + response = await fetch_episodes(podcast_id, page=1, hits=total_episodes) + if response and "data" in response: + result = {"data": response["data"]} + EPISODES_LIST_CACHE[podcast_id] = (result, current_time) + return result + except Exception as e: + logger.error(f"❌ Errore nel recupero degli episodi: {str(e)}") + return {"data": []} + + # Per podcast più grandi, usiamo la paginazione con batch di 500 all_episodes = [] + total_pages = (total_episodes + batch_size - 1) // batch_size - while True: - data = await fetch_episodes(podcast_id, page, batch_size) - episodes = data.get("data", []) - if not episodes: - break + for page in range(1, total_pages + 1): + try: + logger.info( + f"📃 Recupero pagina {page}/{total_pages} (batch di {batch_size} episodi)" + ) + response = await fetch_episodes(podcast_id, page, batch_size) - all_episodes.extend(episodes) - total = data.get("total", 0) - logger.info( - f"📊 Recuperati {len(all_episodes)}/{total} episodi per il podcast {podcast_id}" - ) + if not response or "data" not in response: + logger.error(f"❌ Risposta non valida per la pagina {page}") + continue + + episodes = response["data"] + if not episodes: + logger.warning(f"⚠️ Nessun episodio trovato nella pagina {page}") + break - if len(all_episodes) >= total: - break + all_episodes.extend(episodes) + logger.info(f"✅ Recuperati {len(all_episodes)}/{total_episodes} episodi") - page += 1 + except Exception as e: + logger.error(f"❌ Errore nel recupero della pagina {page}: {str(e)}") + continue + + # Verifica che abbiamo recuperato almeno il 90% degli episodi + if len(all_episodes) < total_episodes * 0.9: + logger.error( + f"❌ Recuperati solo {len(all_episodes)}/{total_episodes} episodi. " + "Non abbastanza per considerare il recupero completo" + ) + return {"data": []} result = {"data": all_episodes} - # Salva nella cache EPISODES_LIST_CACHE[podcast_id] = (result, current_time) + logger.info(f"✨ Recupero completato: {len(all_episodes)}/{total_episodes} episodi") return result @@ -536,12 +653,12 @@ def clean_text(text): pubDate.text = pub_date.strftime("%a, %d %b %Y %H:%M:%S %z") except (ValueError, TypeError) as e: logger.error(f"Errore nel parsing della data: {e}") - pubDate.text = datetime.now(tz=timezone(timedelta(hours=1))).strftime( - "%a, %d %b %Y %H:%M:%S %z" - ) + pubDate.text = datetime.now( + tz=timezone(timedelta(hours=1)) + ).strftime("%a, %d %b %Y %H:%M:%S %z") # Durata dell'episodio - if "milliseconds" in episode: + if "milliseconds" in episode and episode["milliseconds"] is not None: duration_secs = episode["milliseconds"] // 1000 hours = duration_secs // 3600 minutes = (duration_secs % 3600) // 60 @@ -611,7 +728,9 @@ def clean_text(text): # Creazione del channel channel = ET.SubElement( - rdf, "channel", {"rdf:about": f"{base_url}/podcast/{podcast_data['id']}/rdf"} + rdf, + "channel", + {"rdf:about": f"{base_url}/podcast/{podcast_data['id']}/rdf"}, ) # Metadati del canale @@ -708,11 +827,15 @@ async def search_podcasts(query: str, request: Request): podcasts = await fetch_podcasts() base_url = request.base_url titles = [podcast["title"] for podcast in podcasts["data"]] - matches = get_close_matches(query, titles, n=1, cutoff=0.2) # Adjust cutoff as needed + matches = get_close_matches( + query, titles, n=1, cutoff=0.2 + ) # Adjust cutoff as needed if matches: matched_title = matches[0] - matching_podcast = next((p for p in podcasts["data"] if p["title"] == matched_title), None) + matching_podcast = next( + (p for p in podcasts["data"] if p["title"] == matched_title), None + ) if matching_podcast: podcast_id = matching_podcast["id"] @@ -725,7 +848,9 @@ async def search_podcasts(query: str, request: Request): "podcast_api": f"{base_url}podcast/{podcast_id}/last", } - return JSONResponse(content={"message": "No matching podcast found"}, status_code=404) + return JSONResponse( + content={"message": "No matching podcast found"}, status_code=404 + ) @app.get( @@ -774,12 +899,16 @@ async def healthcheck(): @app.get("/podcast/{podcast_id}/rss") async def get_podcast_rss( - podcast_id: int = Path(...), request: Request = None, db: AsyncSession = Depends(get_db) + podcast_id: int = Path(...), + request: Request = None, + db: AsyncSession = Depends(get_db), ): try: # Recuperiamo le informazioni del podcast podcasts = await fetch_podcasts() - podcast_info = next((p for p in podcasts["data"] if p["id"] == podcast_id), None) + podcast_info = next( + (p for p in podcasts["data"] if p["id"] == podcast_id), None + ) if not podcast_info: raise HTTPException(status_code=404, detail="Podcast non trovato") @@ -793,7 +922,9 @@ async def get_podcast_rss( api_episodes = await fetch_all_episodes(podcast_id) # Otteniamo o creiamo il podcast nel database - db_podcast = await get_or_create_podcast(db, str(podcast_id), api_episodes["data"][0]) + db_podcast = await get_or_create_podcast( + db, str(podcast_id), api_episodes["data"][0] + ) if not db_podcast: raise HTTPException( status_code=404, detail="Impossibile creare o recuperare il podcast" @@ -844,7 +975,11 @@ async def get_podcast_rss( "description": episode.description, "content_html": episode.description, "episode_raw_url": episode.audio_url, - "date": episode.publication_date.isoformat() if episode.publication_date else None, + "date": ( + episode.publication_date.isoformat() + if episode.publication_date + else None + ), "milliseconds": episode.duration * 1000 if episode.duration else None, } episodes_data["data"].append(episode_data) @@ -862,7 +997,9 @@ async def get_podcast_rss( request_base_url = str(request.base_url).rstrip("/") # Generiamo il feed RSS - rss_feed = rss_generator.generate_feed(podcast_data, episodes_data, request_base_url) + rss_feed = rss_generator.generate_feed( + podcast_data, episodes_data, request_base_url + ) return Response(content=rss_feed, media_type="application/rss+xml") except Exception as e: @@ -885,11 +1022,17 @@ async def get_last_episode_info(podcast_id: int, db: AsyncSession = None) -> tup episodes, needs_update = await get_podcast_episodes(db, podcast_id) if episodes and not needs_update: # Ordiniamo per data di pubblicazione (più recenti prima) - episodes.sort(key=lambda x: x.publication_date or datetime.min, reverse=True) + episodes.sort( + key=lambda x: x.publication_date or datetime.min, reverse=True + ) if episodes: latest = episodes[0] cached_info = ( - latest.publication_date.isoformat() if latest.publication_date else None, + ( + latest.publication_date.isoformat() + if latest.publication_date + else None + ), latest.title, latest.duration * 1000 if latest.duration else None, ) @@ -925,54 +1068,63 @@ async def get_last_episode_date(podcast_id: int) -> str: @app.get("/podcasts/directory", response_class=HTMLResponse) async def podcast_directory(request: Request, db: AsyncSession = Depends(get_db)): """Mostra la directory di tutti i podcast come pagina principale.""" - # Recuperiamo la lista dei podcast con una sola chiamata - podcasts = await fetch_podcasts(page=1, hits=100) - logger.debug(f"Recuperati {len(podcasts['data'])} podcast") - - # Prepariamo i dati per il template, includendo l'ultimo episodio - podcast_list = [] - for podcast in podcasts["data"]: - logger.debug(f"Elaborazione podcast {podcast['id']}: {podcast['title']}") - - # Recuperiamo le informazioni dell'ultimo episodio in modo ottimizzato - last_episode_date, last_episode_title, last_episode_duration = await get_last_episode_info( - podcast["id"], db + try: + # Prima controlliamo la cache + current_time = datetime.now().timestamp() + needs_update = False + + if "directory" in PODCASTS_CACHE: + cached_data, cache_time = PODCASTS_CACHE["directory"] + if current_time - cache_time < CACHE_TTL: + logger.info("🎯 Cache HIT - Directory podcast trovata in cache") + podcast_list = cached_data + else: + needs_update = True + podcast_list = cached_data # Usiamo i dati cached mentre aggiorniamo + else: + # Se non c'è cache, facciamo un aggiornamento completo + needs_update = True + await update_podcast_directory_cache() + cached_data, _ = PODCASTS_CACHE.get("directory", ([], current_time)) + podcast_list = cached_data + + return templates.TemplateResponse( + "podcast_directory.html", + { + "request": request, + "podcasts": podcast_list, + "year": datetime.now().year, + "needs_update": needs_update, + }, ) - logger.debug( - f"Informazioni ultimo episodio: data={last_episode_date}, titolo={last_episode_title}, durata={last_episode_duration}" + except Exception as e: + logger.error(f"Error fetching podcast directory: {str(e)}", exc_info=True) + raise HTTPException( + status_code=500, + detail={"message": "Error fetching podcast directory", "error": str(e)}, ) - podcast_data = { - "id": podcast["id"], - "title": clean_html_text(podcast["title"]), - "image": podcast["image"], - "description": clean_html_text(podcast["description"]), - "author": clean_html_text(podcast["author"]), - "rss_url": f"/podcast/{podcast['id']}/rss", - "slug": podcast["slug"], - "last_episode_date": last_episode_date, - "last_episode_title": ( - clean_html_text(last_episode_title) if last_episode_title else None - ), - "last_episode_duration": ( - format_duration(last_episode_duration) if last_episode_duration else "N/D" - ), - } - logger.debug(f"Dati preparati per il template: {podcast_data}") - podcast_list.append(podcast_data) - # Ordiniamo i podcast per data dell'ultimo episodio - podcast_list.sort( - key=lambda x: ( - x["last_episode_date"] if x["last_episode_date"] else "1970-01-01T00:00:00+00:00" - ), - reverse=True, - ) +@app.post("/api/podcasts/update", response_class=JSONResponse) +async def update_podcasts_directory(db: AsyncSession = Depends(get_db)): + """Aggiorna la directory dei podcast in background.""" + try: + success = await update_podcast_directory_cache() + if not success: + raise HTTPException( + status_code=500, detail={"message": "Error updating podcast directory"} + ) - return templates.TemplateResponse( - "podcast_directory.html", - {"request": request, "podcasts": podcast_list, "year": datetime.now().year}, - ) + cached_data, _ = PODCASTS_CACHE.get( + "directory", ([], datetime.now().timestamp()) + ) + return JSONResponse({"success": True, "podcasts": cached_data}) + except Exception as e: + logger.error(f"Error updating podcast directory: {str(e)}", exc_info=True) + raise HTTPException( + status_code=500, + detail={"message": "Error updating podcast directory", "error": str(e)}, + ) @app.get("/podcast/{podcast_id}/episodes", response_class=HTMLResponse) @@ -986,7 +1138,9 @@ async def podcast_episodes( try: # Prima recuperiamo le informazioni del podcast podcasts = await fetch_podcasts() - podcast_info = next((p for p in podcasts["data"] if p["id"] == podcast_id), None) + podcast_info = next( + (p for p in podcasts["data"] if p["id"] == podcast_id), None + ) if not podcast_info: raise HTTPException(status_code=404, detail="Podcast non trovato") @@ -1005,34 +1159,6 @@ async def podcast_episodes( # Controlliamo nel database episodes, needs_update = await get_podcast_episodes(db, podcast_id) - if needs_update: - # Se dobbiamo aggiornare, aspettiamo il rate limiter - await api_rate_limiter.wait() - # Prendiamo i dati freschi dall'API - response = await fetch_episodes( - podcast_id, hits=10000 - ) # Prendiamo tutti gli episodi disponibili - podcast_data = response.get("data", []) - - if not podcast_data: - raise HTTPException( - status_code=404, detail="Nessun episodio trovato per questo podcast" - ) - - # Otteniamo prima il podcast esistente o ne creiamo uno nuovo - db_podcast = await get_or_create_podcast(db, str(podcast_id), podcast_data[0]) - if not db_podcast: - raise HTTPException( - status_code=404, detail="Impossibile creare o recuperare il podcast" - ) - - # Salviamo nel database - await save_episodes(db, db_podcast, podcast_data) - await update_podcast_check_time(db, db_podcast) - - # Rileggiamo gli episodi dal database - episodes, _ = await get_podcast_episodes(db, db_podcast.id) - # Ordiniamo gli episodi per data di pubblicazione (più recenti prima) episodes.sort(key=lambda x: x.publication_date or datetime.min, reverse=True) @@ -1059,30 +1185,6 @@ async def podcast_episodes( pages.append("...") pages.append(total_pages) - if not episodes: - # Creiamo un oggetto pagination vuoto - pagination = { - "current_page": page, - "per_page": per_page, - "total_items": 0, - "total_episodes": 0, # Alias per il template - "total_pages": 0, - "has_next": False, - "has_prev": False, - "pages": [], - } - - return templates.TemplateResponse( - "podcast_episodes.html", - { - "request": request, - "podcast": podcast, - "episodes": [], - "pagination": pagination, - "podcast_id": podcast_id, - }, - ) - # Paginazione start_idx = (page - 1) * per_page end_idx = start_idx + per_page @@ -1092,12 +1194,13 @@ async def podcast_episodes( pagination = { "current_page": page, "per_page": per_page, - "total_items": total_items, - "total_episodes": total_items, # Alias per il template + "total_items": total_items, # Alias per il template + "total_episodes": total_items, "total_pages": total_pages, "has_next": has_next, "has_prev": has_prev, "pages": pages, + "needs_update": needs_update, # Aggiungiamo questa informazione per il frontend } # Serializziamo gli episodi per il template @@ -1108,14 +1211,18 @@ async def podcast_episodes( "ilpost_id": episode.ilpost_id, "title": clean_html_text(episode.title), "description": clean_html_text(episode.description), - "content_html": episode.description, # Manteniamo anche la versione HTML per chi la vuole usare - "episode_raw_url": episode.audio_url, # Alias per il template + "content_html": episode.description, + "episode_raw_url": episode.audio_url, "audio_url": episode.audio_url, - "date": episode.publication_date.isoformat() if episode.publication_date else None, - "milliseconds": ( + "date": ( + episode.publication_date.isoformat() + if episode.publication_date + else None + ), + "milliseconds": (episode.duration * 1000 if episode.duration else None), + "duration": format_duration( episode.duration * 1000 if episode.duration else None - ), # Alias per il template - "duration": format_duration(episode.duration * 1000 if episode.duration else None), + ), } serialized_episodes.append(episode_dict) @@ -1130,7 +1237,9 @@ async def podcast_episodes( }, ) except Exception as e: - logger.error(f"Error fetching episodes for podcast {podcast_id}: {str(e)}", exc_info=True) + logger.error( + f"Error fetching episodes for podcast {podcast_id}: {str(e)}", exc_info=True + ) raise HTTPException( status_code=500, detail={ @@ -1148,7 +1257,9 @@ def serialize_episode(episode): "title": episode.title, "description": episode.description, "audio_url": episode.audio_url, - "date": episode.publication_date.isoformat() if episode.publication_date else None, + "date": ( + episode.publication_date.isoformat() if episode.publication_date else None + ), "duration": ( episode.duration * 1000 if episode.duration else None ), # Convertiamo in millisecondi @@ -1195,11 +1306,15 @@ async def get_podcast_episodes_json( episodes.sort(key=lambda x: x.publication_date or datetime.min, reverse=True) # Serializziamo gli episodi - serialized_episodes = [serialize_episode(episode) for episode in episodes[:per_page]] + serialized_episodes = [ + serialize_episode(episode) for episode in episodes[:per_page] + ] return {"data": serialized_episodes} except Exception as e: - logger.error(f"Error fetching episodes for podcast {podcast_id}: {str(e)}", exc_info=True) + logger.error( + f"Error fetching episodes for podcast {podcast_id}: {str(e)}", exc_info=True + ) raise HTTPException( status_code=500, detail={ @@ -1214,7 +1329,9 @@ async def get_podcast_rdf(podcast_id: int = Path(...), request: Request = None): """Genera il feed RDF per un podcast specifico.""" try: podcasts = await fetch_podcasts() - podcast_info = next((p for p in podcasts["data"] if p["id"] == podcast_id), None) + podcast_info = next( + (p for p in podcasts["data"] if p["id"] == podcast_id), None + ) if not podcast_info: raise HTTPException(status_code=404, detail="Podcast non trovato") @@ -1246,7 +1363,9 @@ async def clear_cache(): return {"message": "Cache pulita con successo"} -async def fetch_episode_details(podcast_id: int, episode_id: int, db: AsyncSession = None): +async def fetch_episode_details( + podcast_id: int, episode_id: int, db: AsyncSession = None +): """Recupera i dettagli di un singolo episodio.""" try: # Se abbiamo una sessione DB, prima cerchiamo lì @@ -1255,7 +1374,9 @@ async def fetch_episode_details(podcast_id: int, episode_id: int, db: AsyncSessi result = await db.execute(stmt) episode = result.scalar_one_or_none() if episode and episode.description_verified: - logger.info(f"🎯 Cache HIT - Dettagli episodio {episode_id} trovati nel database") + logger.info( + f"🎯 Cache HIT - Dettagli episodio {episode_id} trovati nel database" + ) return { "data": { "id": episode.id, @@ -1268,13 +1389,17 @@ async def fetch_episode_details(podcast_id: int, episode_id: int, db: AsyncSessi if episode.publication_date else None ), - "milliseconds": episode.duration * 1000 if episode.duration else None, + "milliseconds": ( + episode.duration * 1000 if episode.duration else None + ), } } # Se non lo troviamo nel DB o la descrizione non è verificata, lo recuperiamo dall'API url = f"{PODCAST_API_BASE_URL}/{podcast_id}/{episode_id}/" - logger.warning(f"📝 Recupero dettagli episodio {episode_id} del podcast {podcast_id}") + logger.warning( + f"📝 Recupero dettagli episodio {episode_id} del podcast {podcast_id}" + ) token = await get_token() headers = {"Apikey": "testapikey", "Token": token} @@ -1318,7 +1443,9 @@ async def fetch_episode_details(podcast_id: int, episode_id: int, db: AsyncSessi episode.title = episode_data.get("title", episode.title) episode.description = description episode.description_verified = True - episode.audio_url = episode_data.get("episode_raw_url", episode.audio_url) + episode.audio_url = episode_data.get( + "episode_raw_url", episode.audio_url + ) if episode_data.get("date"): episode.publication_date = datetime.fromisoformat( episode_data["date"].replace("Z", "+00:00") @@ -1350,7 +1477,9 @@ async def fetch_episode_details(podcast_id: int, episode_id: int, db: AsyncSessi @app.get("/api/podcast/{podcast_id}/episode/{episode_id}/description") async def get_episode_description( - podcast_id: int = Path(...), episode_id: str = Path(...), db: AsyncSession = Depends(get_db) + podcast_id: int = Path(...), + episode_id: str = Path(...), + db: AsyncSession = Depends(get_db), ): """Recupera la descrizione di un singolo episodio.""" try: @@ -1367,19 +1496,27 @@ async def get_episode_description( # Se non lo troviamo nel database o non ha descrizione, lo recuperiamo dall'API await api_rate_limiter.wait() - if episode_details := await fetch_episode_details(podcast_id, int(episode_id), db=db): + if episode_details := await fetch_episode_details( + podcast_id, int(episode_id), db=db + ): # Estraiamo i dati secondo lo schema API_PODCAST_EPISODE episode_data = episode_details.get("data", {}) - description = episode_data.get("content_html", "") or episode_data.get("summary", "") + description = episode_data.get("content_html", "") or episode_data.get( + "summary", "" + ) - return {"description": clean_html_text(description), "content_html": description} + return { + "description": clean_html_text(description), + "content_html": description, + } # Se non abbiamo trovato nulla, restituiamo una descrizione vuota return {"description": "", "content_html": ""} except Exception as e: logger.error( - f"Errore nel recupero descrizione episodio {episode_id}: {str(e)}", exc_info=True + f"Errore nel recupero descrizione episodio {episode_id}: {str(e)}", + exc_info=True, ) raise HTTPException( status_code=500, @@ -1392,7 +1529,9 @@ async def get_episode_description( @app.post("/api/podcast/{podcast_id}/episode/{episode_id}/refresh") async def refresh_episode( - podcast_id: int = Path(...), episode_id: str = Path(...), db: AsyncSession = Depends(get_db) + podcast_id: int = Path(...), + episode_id: str = Path(...), + db: AsyncSession = Depends(get_db), ): """Forza il refresh delle informazioni di un episodio dal backend.""" try: @@ -1408,7 +1547,9 @@ async def refresh_episode( # Recuperiamo i nuovi dati dall'API await api_rate_limiter.wait() - episode_details = await fetch_episode_details(podcast_id, int(episode_id), db=db) + episode_details = await fetch_episode_details( + podcast_id, int(episode_id), db=db + ) if not episode_details: raise HTTPException(status_code=404, detail="Episodio non trovato") @@ -1417,7 +1558,8 @@ async def refresh_episode( except Exception as e: logger.error( - f"Errore nell'aggiornamento dell'episodio {episode_id}: {str(e)}", exc_info=True + f"Errore nell'aggiornamento dell'episodio {episode_id}: {str(e)}", + exc_info=True, ) raise HTTPException( status_code=500, @@ -1426,3 +1568,230 @@ async def refresh_episode( "error": str(e), }, ) + + +@app.post("/api/podcast/{podcast_id}/update", response_class=JSONResponse) +async def update_podcast( + podcast_id: int = Path(...), + db: AsyncSession = Depends(get_db), +): + """Forza l'aggiornamento di un podcast specifico.""" + try: + # Invalidiamo la cache per questo podcast + stmt = select(Podcast).where(Podcast.ilpost_id == str(podcast_id)) + result = await db.execute(stmt) + podcast = result.scalar_one_or_none() + + if podcast: + # Forziamo il refresh impostando last_checked a None + podcast.last_checked = None + await db.commit() + + # Recuperiamo i nuovi dati dall'API usando il metodo che recupera tutti gli episodi + await api_rate_limiter.wait() + response = await fetch_all_episodes(podcast_id, batch_size=500) + + if not response or "data" not in response: + raise HTTPException( + status_code=404, detail="Nessun episodio trovato per questo podcast" + ) + + podcast_data = response["data"] + + # Otteniamo o creiamo il podcast + podcast = await get_or_create_podcast(db, str(podcast_id), podcast_data[0]) + if not podcast: + raise HTTPException( + status_code=404, detail="Impossibile creare o recuperare il podcast" + ) + + # Salviamo gli episodi + await save_episodes(db, podcast, podcast_data) + await update_podcast_check_time(db, podcast) + + # Rileggiamo gli episodi dal database + episodes, _ = await get_podcast_episodes(db, podcast_id) + + # Ordiniamo gli episodi per data di pubblicazione (più recenti prima) + episodes.sort(key=lambda x: x.publication_date or datetime.min, reverse=True) + + # Serializziamo gli episodi + serialized_episodes = [] + for episode in episodes: + episode_dict = { + "id": episode.id, + "ilpost_id": episode.ilpost_id, + "title": clean_html_text(episode.title), + "description": clean_html_text(episode.description), + "content_html": episode.description, + "episode_raw_url": episode.audio_url, + "audio_url": episode.audio_url, + "date": ( + episode.publication_date.isoformat() + if episode.publication_date + else None + ), + "milliseconds": (episode.duration * 1000 if episode.duration else None), + "duration": format_duration( + episode.duration * 1000 if episode.duration else None + ), + } + serialized_episodes.append(episode_dict) + + return JSONResponse({"success": True, "episodes": serialized_episodes}) + except Exception as e: + logger.error(f"Error updating podcast {podcast_id}: {str(e)}", exc_info=True) + raise HTTPException( + status_code=500, + detail={ + "message": f"Error updating podcast {podcast_id}", + "error": str(e), + }, + ) + + +# Aggiungiamo le funzioni ai filtri di Jinja2 +templates.env.filters.update( + { + "formatDateMain": format_date_main, + "formatDateYear": format_date_year, + "formatDateTime": format_date_time, + "escapejs": escapejs, + "clean_text": clean_html_text, + "format_duration": format_duration, + } +) + + +async def check_updates_from_bff(): + """Controlla gli aggiornamenti usando l'endpoint BFF Homepage. + Restituisce un dizionario con gli ID dei podcast e le date degli ultimi episodi.""" + token = get_cached_token() + headers = {"Apikey": "testapikey", "Token": token} + + try: + async with httpx.AsyncClient(verify=False, timeout=30.0) as client: + response = await client.get( + "https://api-prod.ilpost.it/podcast/v1/bff/hp", headers=headers + ) + response.raise_for_status() + data = response.json() + + # Dizionario per tenere traccia dell'ultimo episodio per ogni podcast + latest_episodes = {} + + if "data" in data: + for component in data["data"]: + if "data" in component: + for episode in component["data"]: + if "parent" in episode and "id" in episode["parent"]: + podcast_id = episode["parent"]["id"] + episode_date = episode.get("date") + + # Aggiorniamo solo se questa è la data più recente per questo podcast + if podcast_id not in latest_episodes or ( + episode_date + and ( + not latest_episodes[podcast_id] + or episode_date + > latest_episodes[podcast_id]["date"] + ) + ): + latest_episodes[podcast_id] = { + "date": episode_date, + "title": episode["parent"].get("title"), + "last_episode_title": episode.get("title"), + "last_episode_duration": episode.get( + "milliseconds" + ), + } + + return latest_episodes + except Exception as e: + logger.error(f"Errore nel controllo degli aggiornamenti BFF: {str(e)}") + return {} + + +async def update_podcast_directory_cache(): + """Aggiorna la cache della directory dei podcast usando le informazioni dal BFF.""" + try: + # Otteniamo gli ultimi aggiornamenti dal BFF + latest_updates = await check_updates_from_bff() + + if not latest_updates: + logger.warning("Nessun aggiornamento trovato dal BFF") + return False + + # Recuperiamo la cache corrente + current_time = datetime.now().timestamp() + needs_full_update = True + + if "directory" in PODCASTS_CACHE: + cached_data, cache_time = PODCASTS_CACHE["directory"] + podcast_list = cached_data + needs_full_update = False + + # Aggiorniamo solo i podcast che hanno nuovi episodi + for podcast in podcast_list: + podcast_id = podcast["id"] + if podcast_id in latest_updates: + latest = latest_updates[podcast_id] + current_date = podcast.get("last_episode_date") + + if not current_date or latest["date"] > current_date: + logger.info( + f"Aggiornamento podcast {podcast['title']} con nuovo episodio" + ) + podcast.update( + { + "last_episode_date": latest["date"], + "last_episode_title": clean_html_text( + latest["last_episode_title"] + ), + "last_episode_duration": format_duration( + latest["last_episode_duration"] + ), + } + ) + else: + # Se non abbiamo cache, dobbiamo fare un aggiornamento completo + podcasts = await fetch_podcasts(page=1, hits=100) + podcast_list = [] + + for podcast in podcasts["data"]: + latest = latest_updates.get(podcast["id"], {}) + podcast_data = { + "id": podcast["id"], + "title": clean_html_text(podcast["title"]), + "image": podcast["image"], + "description": clean_html_text(podcast["description"]), + "author": clean_html_text(podcast["author"]), + "rss_url": f"/podcast/{podcast['id']}/rss", + "slug": podcast["slug"], + "last_episode_date": latest.get("date"), + "last_episode_title": clean_html_text( + latest.get("last_episode_title", "") + ), + "last_episode_duration": format_duration( + latest.get("last_episode_duration") + ), + } + podcast_list.append(podcast_data) + + # Ordiniamo i podcast per data dell'ultimo episodio + podcast_list.sort( + key=lambda x: ( + x["last_episode_date"] + if x["last_episode_date"] + else "1970-01-01T00:00:00+00:00" + ), + reverse=True, + ) + + # Aggiorniamo la cache + PODCASTS_CACHE["directory"] = (podcast_list, current_time) + + return True + except Exception as e: + logger.error(f"Errore nell'aggiornamento della directory: {str(e)}") + return False diff --git a/src/static/css/episodes.css b/src/static/css/episodes.css new file mode 100644 index 0000000..9ae9695 --- /dev/null +++ b/src/static/css/episodes.css @@ -0,0 +1,487 @@ +/* Stili per gli episodi */ +.episode-title { + font-size: 1.1em; + font-weight: 600; + margin-bottom: 0.5em; + color: var(--text); +} + +.episode-description { + margin-top: 8px; + padding: 8px; + background-color: var(--surface0); + border-radius: 4px; + font-size: 0.9em; + color: var(--subtext); + line-height: 1.4; + position: relative; + transition: max-height 0.3s ease-out; +} + +.episode-description:not(.expanded) img { + display: none; +} + +.episode-description.truncated { + max-height: 4.2em; + overflow: hidden; +} + +.episode-description.truncated::after { + content: ''; + position: absolute; + bottom: 0; + right: 0; + width: 100%; + height: 1.4em; + background: linear-gradient(transparent, var(--surface0)); +} + +.episode-description p { + margin: 0.5em 0; +} + +.episode-description a { + color: var(--accent); + text-decoration: none; +} + +.episode-description a:hover { + text-decoration: underline; +} + +.show-more { + font-size: 0.8em; + color: var(--accent); + cursor: pointer; + margin-top: 0.2em; + display: inline-block; +} + +.show-more:hover { + text-decoration: underline; +} + +.show-description-button { + background: none; + border: none; + color: var(--accent); + cursor: pointer; + padding: 4px 8px; + margin: 4px 0; + border-radius: 4px; + transition: background-color 0.2s; +} + +.show-description-button:hover { + background-color: var(--surface2); +} + +.show-description-button:disabled { + cursor: wait; + opacity: 0.7; +} + +.date-cell { + display: flex; + flex-direction: row; + align-items: center; + gap: 8px; + white-space: nowrap; +} + +.date-main { + display: flex; + align-items: center; + gap: 4px; +} + +.date-year { + color: var(--subtext); + font-size: 0.9em; +} + +.date-time { + margin-left: 8px; + color: var(--subtext); + font-size: 0.85em; + display: flex; + align-items: center; + gap: 4px; +} + +.date-time i { + font-size: 0.9em; +} + +.pagination { + display: flex; + align-items: center; + justify-content: flex-start; + gap: 8px; + margin: 8px 0; + padding: 4px 8px; + background: var(--surface0); + border-radius: 4px; + box-shadow: 0 1px 2px rgba(0, 0, 0, 0.1); + font-size: 0.85em; +} + +.pagination-info { + color: var(--subtext); + white-space: nowrap; + margin-right: 8px; +} + +.pagination-controls { + display: flex; + align-items: center; + gap: 2px; +} + +.page-button { + display: inline-flex; + align-items: center; + justify-content: center; + min-width: 24px; + height: 24px; + padding: 0 6px; + border-radius: 12px; + border: none; + background: var(--surface1); + color: var(--text); + text-decoration: none; + font-weight: 500; + transition: all 0.2s ease; + cursor: pointer; + position: relative; +} + +.page-button:hover { + background: var(--surface2); + transform: translateY(-1px); +} + +.page-button.active { + background: var(--accent); + color: white; + box-shadow: 0 1px 2px rgba(0, 0, 0, 0.2); +} + +.per-page-selector { + display: flex; + align-items: center; + margin-left: auto; + gap: 4px; +} + +.per-page-selector select { + padding: 2px 6px; + border-radius: 12px; + border: none; + background: var(--surface1); + color: var(--text); + cursor: pointer; + transition: background-color 0.2s; +} + +.per-page-selector select:hover { + background: var(--surface2); +} + +.page-dots { + position: relative; +} + +.page-selector { + position: absolute; + top: 100%; + left: 50%; + transform: translateX(-50%); + margin-top: 4px; + padding: 4px; + background: var(--mantle); + border: 1px solid var(--surface2); + border-radius: 4px; + box-shadow: 0 2px 8px rgba(0, 0, 0, 0.3); + z-index: 1000; + display: none; + flex-direction: column; + min-width: 80px; + max-height: 160px; + overflow-y: auto; + backdrop-filter: blur(10px); + scrollbar-width: thin; + scrollbar-color: var(--surface2) transparent; +} + +.page-selector::-webkit-scrollbar { + width: 4px; +} + +.page-selector::-webkit-scrollbar-track { + background: transparent; +} + +.page-selector::-webkit-scrollbar-thumb { + background-color: var(--surface2); + border-radius: 2px; +} + +/* Mostra il selettore quando il pulsante è in hover o il selettore è visibile */ +.page-dots:hover .page-selector, +.page-selector.visible { + display: flex; +} + +.page-selector::before { + content: ''; + position: absolute; + top: -12px; + left: 0; + right: 0; + height: 12px; + background: transparent; +} + +.page-option { + padding: 4px 8px; + text-decoration: none; + color: var(--text); + border-radius: 2px; + transition: all 0.2s ease; + background: var(--base); + border: 1px solid transparent; + font-size: 0.85em; + margin: 1px 0; +} + +.page-option:hover { + background: var(--surface1); + border-color: var(--surface2); + transform: translateX(2px); +} + +.page-option.active { + background: var(--accent); + color: white; + font-weight: 500; + border-color: var(--accent); +} + +.actions { + display: flex; + align-items: center; + gap: 8px; + white-space: nowrap; +} + +.play-button, +.download-button { + display: inline-flex; + align-items: center; + justify-content: center; + width: 32px; + height: 32px; + padding: 0; + border: none; + border-radius: 16px; + background: var(--surface1); + color: var(--text); + cursor: pointer; + transition: all 0.2s ease; + text-decoration: none; +} + +.play-button:hover, +.download-button:hover { + background: var(--surface2); + transform: translateY(-1px); +} + +table { + width: 100%; + border-collapse: collapse; + table-layout: fixed; +} + +th, td { + padding: 12px; + text-align: left; + vertical-align: top; +} + +th { + background: var(--surface0); + color: var(--subtext); + font-weight: 500; + font-size: 0.9em; +} + +td { + border-bottom: 1px solid var(--surface0); +} + +/* Colonne con larghezze relative usando flex */ +.episode-cell { + width: auto; + min-width: 0; + flex: 1; + position: relative; +} + +.date-cell { + width: 160px; + min-width: 160px; +} + +.duration-cell { + width: 100px; + min-width: 100px; +} + +.actions-cell { + width: 100px; + min-width: 100px; + text-align: right; +} + +/* Stili per il contenuto delle celle */ +.episode-title { + word-wrap: break-word; + overflow-wrap: break-word; + margin-right: 8px; +} + +.actions { + display: flex; + align-items: center; + justify-content: flex-end; + gap: 8px; +} + +/* Stile per la tabella degli episodi */ +.episodes-table { + width: 100%; + border-collapse: collapse; + table-layout: fixed; +} + +.episodes-table th, .episodes-table td { + padding: 12px; + text-align: left; + vertical-align: top; +} + +/* Larghezze colonne */ +.episodes-table th:nth-child(1), .episodes-table td:nth-child(1) { + width: 65%; /* Colonna episodio più larga */ +} + +.episodes-table th:nth-child(2), .episodes-table td:nth-child(2) { + width: 20%; /* Data ottimizzata */ +} + +.episodes-table th:nth-child(3), .episodes-table td:nth-child(3) { + width: 5%; /* Durata più compatta */ +} + +.episodes-table th:nth-child(4), .episodes-table td:nth-child(4) { + width: 10%; /* Azioni più compatta */ + text-align: right; /* Allineamento a destra per titolo e contenuto */ +} + +.episode-title-row { + display: flex; + align-items: center; + gap: 8px; +} + +.episode-expand-button { + background: none; + border: none; + color: var(--subtext); + cursor: pointer; + padding: 4px; + transition: transform 0.2s ease; +} + +.episode-expand-button.expanded { + transform: rotate(180deg); +} + +.episode-expand-button:hover { + color: var(--text); +} + +.episode-actions { + display: flex; + gap: 4px; + margin-left: auto; +} + +.refresh-button { + background: none; + border: none; + color: var(--subtext); + cursor: pointer; + padding: 4px; + border-radius: 4px; + transition: all 0.2s ease; +} + +.refresh-button:hover { + color: var(--text); + background: var(--surface1); +} + +.refresh-button:disabled { + opacity: 0.5; + cursor: wait; +} + +.actions-cell { + text-align: center; /* Centra il testo dell'header */ +} + +.actions { + display: flex; + align-items: center; + justify-content: flex-end; + gap: 8px; +} + +.refresh-button, +.play-button, +.download-button { + display: inline-flex; + align-items: center; + justify-content: center; + width: 32px; + height: 32px; + padding: 0; + border: none; + border-radius: 16px; + background: var(--surface1); + color: var(--text); + cursor: pointer; + transition: all 0.2s ease; + text-decoration: none; +} + +.refresh-button:hover, +.play-button:hover, +.download-button:hover { + background: var(--surface2); + transform: translateY(-1px); + box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1); +} + +.refresh-button:disabled, +.play-button:disabled, +.download-button:disabled { + opacity: 0.5; + cursor: wait; + transform: none; + box-shadow: none; +} \ No newline at end of file diff --git a/src/static/css/loading.css b/src/static/css/loading.css new file mode 100644 index 0000000..031daa1 --- /dev/null +++ b/src/static/css/loading.css @@ -0,0 +1,74 @@ +.loading-container { + position: fixed; + top: 0; + left: 0; + width: 100%; + height: 4px; + background-color: var(--surface0); + z-index: 1000; + overflow: hidden; + transition: opacity 0.3s ease-out; +} + +.loading-bar { + height: 100%; + width: 0; + background-color: var(--accent); + transition: width 0.5s ease-out; + position: relative; +} + +.loading-text { + position: absolute; + top: 4px; + left: 0; + width: 100%; + text-align: center; + padding: 4px; + background-color: var(--surface0); + color: var(--accent); + font-size: 0.8em; + transform: translateY(0); + transition: transform 0.3s ease-out, opacity 0.3s ease-out; + opacity: 1; +} + +.loading-text.hidden { + transform: translateY(-100%); + opacity: 0; +} + +.loading-bar::after { + content: ''; + position: absolute; + top: 0; + left: 0; + width: 200%; + height: 100%; + background: linear-gradient( + 90deg, + transparent, + var(--accent-light, rgba(255, 255, 255, 0.2)), + transparent + ); + animation: loading-shimmer 1.5s infinite; +} + +@keyframes loading-shimmer { + 0% { + transform: translateX(-50%); + } + 100% { + transform: translateX(0%); + } +} + +.loading-hidden { + opacity: 0; +} + +.content-loading { + opacity: 0.7; + pointer-events: none; + transition: opacity 0.3s ease-out; +} \ No newline at end of file diff --git a/src/static/css/player.css b/src/static/css/player.css new file mode 100644 index 0000000..4d8d3fd --- /dev/null +++ b/src/static/css/player.css @@ -0,0 +1,248 @@ +.audio-player { + position: fixed; + bottom: 0; + left: 0; + right: 0; + background: var(--mantle); + border-top: 1px solid var(--surface0); + padding: 12px; + z-index: 1000; + transition: all 0.3s ease; + box-shadow: 0 -2px 10px rgba(0, 0, 0, 0.2); + display: none; + opacity: 0; + transform: translateY(100%); + backdrop-filter: blur(20px); + -webkit-backdrop-filter: blur(20px); + background-color: rgba(var(--mantle-rgb), 0.95); + max-height: 80vh; + overflow-y: auto; +} + +.audio-player.visible { + display: block; + opacity: 1; + transform: translateY(0); +} + +.audio-player.minimized { + padding: 6px; + max-height: unset; + overflow-y: hidden; +} + +.audio-player.minimized .player-content { + max-width: 1200px; + max-height: 40px; + overflow: hidden; +} + +.audio-player.minimized .podcast-thumbnail { + width: 32px; + height: 32px; + min-width: 32px; +} + +.audio-player.minimized .now-playing { + display: none; +} + +.audio-player.minimized .player-main { + margin-left: 8px; +} + +.audio-player.minimized audio { + max-width: 300px; +} + +.player-content { + display: flex; + align-items: flex-start; + gap: 16px; + max-width: 80%; + margin: 0 auto; + transition: all 0.3s ease; +} + +.podcast-thumbnail { + width: 64px; + height: 64px; + min-width: 64px; + border-radius: 4px; + overflow: hidden; + background: var(--surface0); + transition: all 0.3s ease; +} + +.podcast-thumbnail img { + width: 100%; + height: 100%; + object-fit: cover; +} + +.player-main { + flex: 1; + min-width: 0; + transition: margin 0.3s ease; +} + +.player-controls { + display: flex; + align-items: center; + gap: 8px; + margin-bottom: 4px; +} + +.control-button { + background: none; + border: none; + color: var(--text); + cursor: pointer; + padding: 4px; + border-radius: 4px; + transition: all 0.2s ease; + width: 32px; + height: 32px; + display: flex; + align-items: center; + justify-content: center; +} + +.control-button:hover:not(:disabled) { + background: var(--surface1); + transform: translateY(-1px); +} + +.control-button:disabled { + color: var(--subtext); + cursor: not-allowed; +} + +audio { + flex: 1; + height: 32px; + transition: all 0.3s ease; +} + +.now-playing { + font-size: 0.9em; + color: var(--subtext); + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + transition: all 0.3s ease; + margin-bottom: 8px; +} + +.player-close, +.player-toggle { + position: absolute; + top: 8px; + width: 32px; + height: 32px; + border: none; + color: var(--subtext); + cursor: pointer; + background: var(--surface0); + border-radius: 16px; + display: flex; + align-items: center; + justify-content: center; + transition: all 0.2s ease; + box-shadow: 0 1px 3px rgba(0, 0, 0, 0.1); +} + +.player-close { + right: 8px; +} + +.player-toggle { + right: 48px; +} + +.audio-player:not(.minimized) .player-toggle { + animation: pulse-and-rotate 2s ease-in-out; +} + +.audio-player.minimized .player-toggle { + animation: none; +} + +.player-close:hover, +.player-toggle:hover { + color: var(--text); + background: var(--surface1); + transform: translateY(-1px); + box-shadow: 0 2px 5px rgba(0, 0, 0, 0.2); +} + +@keyframes pulse-and-rotate { + 0% { + transform: scale(1) rotate(0deg); + box-shadow: 0 1px 3px rgba(0, 0, 0, 0.1); + } + 50% { + transform: scale(1.1) rotate(180deg); + box-shadow: 0 2px 5px rgba(0, 0, 0, 0.2); + } + 100% { + transform: scale(1) rotate(360deg); + box-shadow: 0 1px 3px rgba(0, 0, 0, 0.1); + } +} + +.episode-description-player { + display: none; + font-size: 0.9em; + color: var(--subtext); + line-height: 1.4; + margin-top: 8px; + max-height: calc(70vh - 150px); + overflow-y: auto; + background: var(--surface0); + padding: 12px; + border-radius: 4px; + scrollbar-width: thin; + scrollbar-color: var(--surface2) transparent; +} + +.episode-description-player::-webkit-scrollbar { + width: 4px; +} + +.episode-description-player::-webkit-scrollbar-track { + background: transparent; +} + +.episode-description-player::-webkit-scrollbar-thumb { + background-color: var(--surface2); + border-radius: 2px; +} + +.episode-description-player img { + max-width: 100%; + height: auto; + border-radius: 4px; + margin: 8px 0; +} + +.episode-description-player p { + margin: 0.5em 0; +} + +.episode-description-player a { + color: var(--accent); + text-decoration: none; +} + +.episode-description-player a:hover { + text-decoration: underline; +} + +.audio-player:not(.minimized) .episode-description-player { + display: block; +} + +.audio-player.minimized .episode-description-player { + display: none; +} \ No newline at end of file diff --git a/src/static/js/episodes.js b/src/static/js/episodes.js new file mode 100644 index 0000000..4748f62 --- /dev/null +++ b/src/static/js/episodes.js @@ -0,0 +1,185 @@ +// Funzione per il refresh dell'episodio +async function refreshEpisode(podcastId, episodeId, button) { + try { + button.disabled = true; + button.innerHTML = ''; + + const response = await fetch(`/api/podcast/${podcastId}/episode/${episodeId}/refresh`, { + method: 'POST' + }); + + if (!response.ok) throw new Error('Errore nel refresh dell\'episodio'); + + // Ricarica la pagina per mostrare i dati aggiornati + window.location.reload(); + + } catch (error) { + console.error('Errore:', error); + alert('Errore nell\'aggiornamento dell\'episodio'); + } finally { + button.disabled = false; + button.innerHTML = ''; + } +} + +// Funzione per espandere/contrarre la descrizione +async function toggleDescription(button) { + const row = button.closest('.episode-title-row'); + const descriptionDiv = row.nextElementSibling; + const episodeId = row.querySelector('.episode-title').getAttribute('data-episode-id'); + const podcastId = document.body.getAttribute('data-podcast-id'); + + button.classList.toggle('expanded'); + + // Se la descrizione è già caricata, la mostriamo/nascondiamo + if (descriptionDiv.innerHTML.trim()) { + descriptionDiv.style.display = descriptionDiv.style.display === 'none' ? 'block' : 'none'; + return; + } + + try { + button.disabled = true; + showLoading(); + + const response = await fetch(`/api/podcast/${podcastId}/episode/${episodeId}/description`); + if (!response.ok) throw new Error('Errore nel caricamento della descrizione'); + + const data = await response.json(); + descriptionDiv.innerHTML = data.content_html; + descriptionDiv.style.display = 'block'; + + // Controlla se ci sono immagini o se il testo è lungo + const hasImages = descriptionDiv.querySelector('img') !== null; + const textLength = descriptionDiv.textContent.length; + + if (hasImages || textLength >= 1000) { + descriptionDiv.classList.add('truncated'); + const showMore = document.createElement('span'); + showMore.className = 'show-more'; + showMore.textContent = 'Mostra tutto'; + showMore.onclick = function(e) { + e.stopPropagation(); + descriptionDiv.classList.toggle('expanded'); + descriptionDiv.classList.toggle('truncated'); + showMore.textContent = descriptionDiv.classList.contains('expanded') ? 'Mostra meno' : 'Mostra tutto'; + }; + descriptionDiv.parentNode.insertBefore(showMore, descriptionDiv.nextSibling); + } + } catch (error) { + console.error('Errore:', error); + descriptionDiv.innerHTML = 'Errore nel caricamento della descrizione'; + descriptionDiv.style.display = 'block'; + } finally { + button.disabled = false; + hideLoading(); + } +} + +// Funzione per cambiare il numero di elementi per pagina +function changePerPage(value) { + showLoading(); + window.location.href = `?page=1&per_page=${value}`; +} + +// Funzioni di utilità per il loading +function showLoading() { + const loadingContainer = document.querySelector('.loading-container'); + const loadingBar = document.querySelector('.loading-bar'); + const loadingText = document.querySelector('.loading-text'); + + loadingContainer.classList.remove('loading-hidden'); + loadingText.classList.remove('hidden'); + loadingContainer.style.display = 'block'; + loadingBar.style.width = '0%'; + + setTimeout(() => { + loadingBar.style.width = '90%'; + }, 50); +} + +function hideLoading() { + const loadingContainer = document.querySelector('.loading-container'); + const loadingBar = document.querySelector('.loading-bar'); + const loadingText = document.querySelector('.loading-text'); + + loadingBar.style.width = '100%'; + setTimeout(() => { + loadingText.classList.add('hidden'); + setTimeout(() => { + loadingContainer.classList.add('loading-hidden'); + }, 500); + }, 200); +} + +async function updateEpisodesInBackground() { + try { + showLoading(); + const podcastId = document.body.getAttribute('data-podcast-id'); + + const response = await fetch(`/api/podcast/${podcastId}/update`, { + method: 'POST' + }); + + if (!response.ok) { + throw new Error('Errore nell\'aggiornamento degli episodi'); + } + + const data = await response.json(); + if (data.success) { + window.location.reload(); + } + } catch (error) { + console.error('Errore nell\'aggiornamento degli episodi:', error); + hideLoading(); + } +} + +// Funzione per mostrare/nascondere il selettore delle pagine +function showPageSelector(button) { + const selector = button.querySelector('.page-selector'); + const allSelectors = document.querySelectorAll('.page-selector'); + + // Nascondi tutti gli altri selettori + allSelectors.forEach(s => { + if (s !== selector) { + s.classList.remove('visible'); + } + }); + + // Mostra/nascondi il selettore corrente + selector.classList.toggle('visible'); + + // Aggiungi event listener per chiudere il selettore quando si clicca fuori + const closeSelector = (e) => { + if (!selector.contains(e.target) && !button.contains(e.target)) { + selector.classList.remove('visible'); + document.removeEventListener('click', closeSelector); + } + }; + + // Aggiungi l'event listener solo se il selettore è visibile + if (selector.classList.contains('visible')) { + // Usiamo setTimeout per evitare che l'event listener venga triggerato immediatamente + setTimeout(() => { + document.addEventListener('click', closeSelector); + }, 0); + } +} + +// Inizializzazione quando il DOM è pronto +document.addEventListener('DOMContentLoaded', function() { + // Gestione dello scroll del selettore delle pagine + const pageSelectors = document.querySelectorAll('.page-selector'); + pageSelectors.forEach(selector => { + selector.addEventListener('wheel', function(e) { + e.preventDefault(); // Previene lo scroll della pagina + this.scrollTop += e.deltaY; // Scrolla il selettore + }); + }); + + // Verifica se è necessario aggiornare gli episodi + const needsUpdate = document.body.getAttribute('data-needs-update') === 'true'; + if (needsUpdate) { + updateEpisodesInBackground(); + } +}); \ No newline at end of file diff --git a/src/static/js/loading-status.js b/src/static/js/loading-status.js new file mode 100644 index 0000000..695d56c --- /dev/null +++ b/src/static/js/loading-status.js @@ -0,0 +1,155 @@ +class LoadingStatus { + constructor() { + this.overlay = document.createElement('div'); + this.overlay.className = 'loading-status-overlay'; + this.overlay.innerHTML = ` +