diff --git a/apps/web/app/(app)/search/SearchComponent.tsx b/apps/web/app/(app)/search/SearchComponent.tsx
index e63c5721..1fe73d28 100644
--- a/apps/web/app/(app)/search/SearchComponent.tsx
+++ b/apps/web/app/(app)/search/SearchComponent.tsx
@@ -12,6 +12,7 @@ import { DialogTrigger, DialogContent } from "@radix-ui/react-dialog";
import ReactPlayer from "react-player";
import Image from "next/image";
import { YoutubeIcon } from "lucide-react";
+import PageGradient from "@/components/Layout/PageGradient";
interface YouTubeVideo {
id: string;
@@ -119,7 +120,7 @@ export default function SearchComponent() {
) : (
- results && results[0] &&
+ results && results[0] && <> >
)}
diff --git a/apps/web/components/Layout/PageGradient.tsx b/apps/web/components/Layout/PageGradient.tsx
index 0080dc35..476ab550 100644
--- a/apps/web/components/Layout/PageGradient.tsx
+++ b/apps/web/components/Layout/PageGradient.tsx
@@ -3,6 +3,7 @@
import { useEffect } from "react";
import { useGradientHover } from "../Providers/GradientHoverProvider";
import { FastAverageColor } from "fast-average-color";
+import getBaseURL from "@/lib/Server/getBaseURL";
export default function PageGradient({ imageSrc }: { imageSrc: string }) {
const { setGradient } = useGradientHover()
@@ -10,8 +11,18 @@ export default function PageGradient({ imageSrc }: { imageSrc: string }) {
useEffect(() => {
const fac = new FastAverageColor();
const getColor = async () => {
- const color = await fac.getColorAsync(imageSrc)
- setGradient(color.hex)
+ const processedImageSrc = !imageSrc.startsWith("http://") && !imageSrc.startsWith("https://")
+ ? `${getBaseURL()}/image/${encodeURIComponent(imageSrc)}?raw=true`
+ : imageSrc;
+
+
+ try {
+ const color = await fac.getColorAsync(processedImageSrc)
+ setGradient(color.hex)
+ } catch (error) {
+ console.error('Error getting color:', error)
+ setGradient('#000000')
+ }
}
getColor()
diff --git a/apps/web/components/Layout/SplashScreen.tsx b/apps/web/components/Layout/SplashScreen.tsx
index 66699c5e..41aa4b2d 100644
--- a/apps/web/components/Layout/SplashScreen.tsx
+++ b/apps/web/components/Layout/SplashScreen.tsx
@@ -6,6 +6,8 @@ import Image from "next/image";
import { useRouter } from "next/navigation";
import React, { useEffect, useState } from "react";
import { useSession } from "../Providers/AuthProvider";
+import { isValid } from "@music/sdk";
+import { deleteCookie } from "cookies-next";
interface SplashScreenProps {
children: React.ReactNode;
@@ -20,21 +22,18 @@ const SplashScreen: React.FC = ({ children }) => {
const checkServerUrl = async () => {
if (isLoading) return;
- const currentPath = window.location.pathname;
- if (
- session?.username &&
- currentPath !== "/" &&
- currentPath !== "/login" &&
- currentPath !== "/login/" &&
- currentPath.startsWith("/home")
- ) {
- setLoading(false);
- return;
- }
-
try {
setLoading(true);
+ const validationResult = await isValid();
+
+ if (!validationResult.status) {
+ deleteCookie('plm_accessToken');
+ deleteCookie('plm_refreshToken');
+ push('/login');
+ return;
+ }
+
const storedServer = localStorage.getItem("server");
const serverUrl = storedServer
? JSON.parse(storedServer).local_address
@@ -54,6 +53,7 @@ const SplashScreen: React.FC = ({ children }) => {
}
if (session?.username) {
+ const currentPath = window.location.pathname;
if (
currentPath === "/" ||
currentPath === "/login" ||
@@ -64,9 +64,11 @@ const SplashScreen: React.FC = ({ children }) => {
return;
}
+ const currentPath = window.location.pathname;
if (currentPath !== "/login" && currentPath !== "/login/") {
push("/login");
}
+
} catch (error) {
console.error("Server check failed:", error);
push("/");
diff --git a/crates/backend/src/main.rs b/crates/backend/src/main.rs
index a41be930..3cfcafa0 100644
--- a/crates/backend/src/main.rs
+++ b/crates/backend/src/main.rs
@@ -10,7 +10,7 @@ use actix_cors::Cors;
use actix_web::HttpResponse;
use actix_web::{middleware, web, App, HttpServer};
use actix_web_httpauth::middleware::HttpAuthentication;
-use routes::authentication::{admin_guard, refresh};
+use routes::authentication::{admin_guard, is_valid, refresh};
use tokio::task;
use tracing::{info, Level};
use tracing_subscriber::FmtSubscriber;
@@ -21,8 +21,7 @@ use routes::authentication::{login, register, validator};
use routes::filesystem;
use routes::image::image;
use routes::music::{
- format_contributing_artists_route, index_library_no_cover_url, process_library, songs_list,
- stream_song, test,
+ index_library_no_cover_url, process_library,
};
use routes::playlist;
use routes::search::{self, populate_search_data};
@@ -188,6 +187,7 @@ async fn main() -> std::io::Result<()> {
.service(login)
.service(register)
.service(refresh)
+ .service(is_valid)
)
.service(image)
.route("/ws", web::get().to(ws))
diff --git a/crates/backend/src/routes/authentication.rs b/crates/backend/src/routes/authentication.rs
index ff486497..bd944ffc 100644
--- a/crates/backend/src/routes/authentication.rs
+++ b/crates/backend/src/routes/authentication.rs
@@ -1,6 +1,6 @@
-use std::env;
+use std::{env, time::{SystemTime, UNIX_EPOCH}};
-use actix_web::{cookie::{Cookie, SameSite}, dev::ServiceRequest, http::header, post, web, HttpRequest, HttpResponse, Responder};
+use actix_web::{cookie::{Cookie, SameSite}, dev::ServiceRequest, get, http::header, post, web, HttpRequest, HttpResponse, Responder};
use actix_web_httpauth::extractors::bearer::BearerAuth;
use argon2::{
password_hash::{rand_core::OsRng, PasswordHash, PasswordHasher, PasswordVerifier, SaltString},
@@ -12,6 +12,7 @@ use dotenvy::dotenv;
use futures::future::{ready, Ready};
use jsonwebtoken::{decode, encode, Algorithm, DecodingKey, EncodingKey, Header, Validation};
use serde::{Deserialize, Serialize};
+use serde_json::json;
use crate::utils::{config::get_jwt_secret, database::{database::establish_connection, models::NewUser}};
@@ -80,6 +81,62 @@ fn generate_refresh_token(user_id: i32, username: &str, role: &String) -> String
encode(&header, &claims, &EncodingKey::from_secret(secret.as_ref())).expect("Token encoding failed")
}
+#[get("/is-valid")]
+pub async fn is_valid(req: HttpRequest) -> impl Responder {
+ let token = if let Some(cookie_header) = req.headers().get(header::COOKIE) {
+ if let Ok(cookie_str) = cookie_header.to_str() {
+ cookie_str.split(';')
+ .filter_map(|cookie| Cookie::parse_encoded(cookie.trim()).ok())
+ .find(|cookie| cookie.name() == "plm_accessToken")
+ .map(|cookie| cookie.value().to_string())
+ } else {
+ None
+ }
+ } else {
+ None
+ };
+
+ let token = match token {
+ Some(t) => t,
+ None => return HttpResponse::Unauthorized().json(json!({
+ "status": false,
+ "message": "No token found in cookies"
+ }))
+ };
+
+ let secret = get_jwt_secret();
+ let mut validation = Validation::new(Algorithm::HS256);
+ validation.leeway = 60;
+ validation.validate_exp = true;
+
+ match decode::(&token, &DecodingKey::from_secret(secret.as_ref()), &validation) {
+ Ok(token_data) => {
+ let current_time = SystemTime::now()
+ .duration_since(UNIX_EPOCH)
+ .unwrap()
+ .as_secs() as usize;
+
+ if token_data.claims.exp < current_time {
+ return HttpResponse::Unauthorized().json(json!({
+ "status": false,
+ "message": "Token expired",
+ "token_type": token_data.claims.token_type
+ }));
+ }
+
+ HttpResponse::Ok().json(json!({
+ "status": true,
+ "token_type": token_data.claims.token_type,
+ "claims": token_data.claims
+ }))
+ },
+ Err(e) => HttpResponse::Unauthorized().json(json!({
+ "status": false,
+ "message": format!("Invalid token: {}", e)
+ }))
+ }
+}
+
#[post("/login")]
pub async fn login(form: web::Json) -> impl Responder {
use crate::utils::database::schema::user::dsl::*;
diff --git a/crates/backend/src/routes/search.rs b/crates/backend/src/routes/search.rs
index 88a9aecf..14681578 100644
--- a/crates/backend/src/routes/search.rs
+++ b/crates/backend/src/routes/search.rs
@@ -9,6 +9,7 @@ use chrono::NaiveDateTime;
use diesel::{ExpressionMethods, QueryDsl, RunQueryDsl};
use dirs;
use lazy_static::lazy_static;
+use rand::seq::SliceRandom;
use rayon::prelude::*;
use serde::{Deserialize, Serialize};
use serde_json::{self, from_str, json};
@@ -17,7 +18,7 @@ use tantivy::query::{FuzzyTermQuery, QueryParser};
use tantivy::schema::{*, Term};
use tantivy::{doc, Index, IndexWriter, ReloadPolicy};
use tokio::task;
-use tracing::error;
+use tracing::{error, info};
use crate::routes::album::fetch_album_info;
use crate::routes::artist::fetch_artist_info;
@@ -547,22 +548,20 @@ async fn search_youtube(query: web::Query) -> Result {
- client
- .get(format!(
- "{}/api/v1/search?q={}&type=video&page=1",
- invidious_url.trim_end_matches('/'),
- search_query
- ))
- .header("Accept", "application/json")
- .send()
- .await?
- .json::>()
- .await?
- },
- Err(_) => { Vec::new() }
- };
+ let invidious_url = env::var("INVIDIOUS_URL")
+ .unwrap_or_else(|_| get_random_invidious_instance());
+
+ let response = client
+ .get(format!(
+ "{}/api/v1/search?q={}&type=video&page=1",
+ invidious_url.trim_end_matches('/'),
+ search_query
+ ))
+ .header("Accept", "application/json")
+ .send()
+ .await?
+ .json::>()
+ .await?;
let limited_results = response.into_iter()
.take(10)
@@ -652,35 +651,45 @@ struct Replies {
continuation: Option,
}
+fn get_random_invidious_instance() -> String {
+ let instances = vec![
+ "https://invidious.nerdvpn.de",
+ "https://inv.nadeko.net",
+ "https://invidious.jing.rocks"
+ ];
+ instances.choose(&mut rand::thread_rng())
+ .unwrap_or(&"https://invidious.snopyta.org")
+ .to_string()
+}
+
#[get("/youtube/comments")]
async fn get_youtube_comments(query: web::Query) -> Result> {
let client = reqwest::Client::new();
let video_id = &query.video_id;
- let comments = match env::var("INVIDIOUS_URL") {
- Ok(invidious_url) => {
- let response = client
- .get(format!(
- "{}/api/v1/comments/{}",
- invidious_url.trim_end_matches('/'),
- video_id
- ))
- .header("Accept", "application/json")
- .send()
- .await?;
-
- if !response.status().is_success() {
- return Ok(HttpResponse::InternalServerError().json(json!({
- "error": "Failed to fetch comments"
- })));
- }
+ let invidious_url = env::var("INVIDIOUS_URL")
+ .unwrap_or_else(|_| get_random_invidious_instance());
- let root = response.json::().await?;
- root
- },
- Err(_) => CommentInfo::default()
- };
+ info!("Using Invidious instance: {}", invidious_url);
+
+ let response = client
+ .get(format!(
+ "{}/api/v1/comments/{}",
+ invidious_url.trim_end_matches('/'),
+ video_id
+ ))
+ .header("Accept", "application/json")
+ .send()
+ .await?;
+
+ if !response.status().is_success() {
+ error!("Failed to fetch comments: {}", response.status());
+ return Ok(HttpResponse::InternalServerError().json(json!({
+ "error": "Failed to fetch comments"
+ })));
+ }
+ let comments = response.json::().await?;
Ok(HttpResponse::Ok().json(comments))
}
diff --git a/packages/music-sdk/src/lib/authentication.ts b/packages/music-sdk/src/lib/authentication.ts
index 3a8c81c8..36ad27aa 100644
--- a/packages/music-sdk/src/lib/authentication.ts
+++ b/packages/music-sdk/src/lib/authentication.ts
@@ -32,6 +32,29 @@ export async function register(data: AuthData): Promise {
return response.data;
}
+interface TokenValidationResponse {
+ status: boolean;
+ token_type?: string;
+ claims?: {
+ sub: string;
+ exp: number;
+ username: string;
+ bitrate: number;
+ token_type: string;
+ role: string;
+ };
+ message?: string;
+}
+
+/**
+ * Check if the current token is valid.
+ * @returns {Promise} - A promise that resolves to the validation response.
+ */
+export async function isValid(): Promise {
+ const response = await axios.get('/auth/is-valid');
+ return response.data;
+}
+
/**
* Login a user.
* @param {AuthData} data - The login data.
diff --git a/packages/music-sdk/src/lib/axios.ts b/packages/music-sdk/src/lib/axios.ts
index 5beb6251..1aebbf8e 100644
--- a/packages/music-sdk/src/lib/axios.ts
+++ b/packages/music-sdk/src/lib/axios.ts
@@ -1,5 +1,5 @@
import axios, { AxiosError, AxiosHeaders, AxiosInstance, AxiosRequestConfig, AxiosResponse, InternalAxiosRequestConfig } from 'axios';
-import { deleteCookie, getCookie } from 'cookies-next';
+import { deleteCookie, getCookie, setCookie } from 'cookies-next';
import { refreshToken } from './authentication';
let localAddress = '';
@@ -15,9 +15,13 @@ const axiosInstance = axios.create({
interface CustomAxiosRequestConfig extends AxiosRequestConfig {
_retry?: boolean;
- _retryCount?: number;
+ _retryAttempt?: number;
+ _retryDelay?: number;
}
+const MAX_RETRY_ATTEMPTS = 3;
+const INITIAL_RETRY_DELAY = 1000;
+
const setupInterceptors = (instance: AxiosInstance): void => {
instance.interceptors.request.use((config: InternalAxiosRequestConfig): InternalAxiosRequestConfig => {
const token = getCookie('plm_accessToken');
@@ -35,30 +39,36 @@ const setupInterceptors = (instance: AxiosInstance): void => {
async (error: AxiosError): Promise => {
const originalRequest = error.config as CustomAxiosRequestConfig;
- if (error.response && error.response.status === 401 && originalRequest) {
- if (!originalRequest._retry) {
- originalRequest._retry = true;
- originalRequest._retryCount = 0;
- }
-
- if (originalRequest._retryCount! < 10) {
- originalRequest._retryCount! += 1;
-
- try {
- const response = await refreshToken();
+ if (error.response?.status === 401 && originalRequest && !originalRequest._retry) {
+ originalRequest._retry = true;
+ originalRequest._retryAttempt = 0;
+ originalRequest._retryDelay = INITIAL_RETRY_DELAY;
+ try {
+ const response = await refreshToken();
+
+ if (response?.token) {
+ setCookie('plm_accessToken', response.token);
if (originalRequest.headers) {
originalRequest.headers.Authorization = `Bearer ${response.token}`;
}
-
return axiosInstance(originalRequest);
- } catch (refreshError) {
- deleteCookie("plm_accessToken");
- return Promise.reject(refreshError);
}
- } else {
- deleteCookie("plm_accessToken");
- return Promise.reject(error);
+
+ throw new Error('No token received from refresh attempt');
+ } catch (refreshError) {
+ console.error('Token refresh failed:', refreshError);
+ deleteCookie('plm_accessToken');
+
+ if (originalRequest._retryAttempt! < MAX_RETRY_ATTEMPTS) {
+ originalRequest._retryAttempt! += 1;
+ const delay = originalRequest._retryDelay! * Math.pow(2, originalRequest._retryAttempt!);
+
+ await new Promise(resolve => setTimeout(resolve, delay));
+ return axiosInstance(originalRequest);
+ }
+
+ throw error;
}
}