feat: Add functionality to refresh video thumbnail

This commit is contained in:
Peifan Li
2025-11-26 11:00:18 -05:00
parent f8d36ff0ac
commit 4f8565bad2
17 changed files with 387 additions and 28 deletions

View File

@@ -1,12 +1,12 @@
{
"name": "backend",
"version": "1.0.0",
"version": "1.0.1",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "backend",
"version": "1.0.0",
"version": "1.0.1",
"license": "ISC",
"dependencies": {
"axios": "^1.8.1",

View File

@@ -8,12 +8,12 @@ import downloadManager from "../services/downloadManager";
import * as downloadService from "../services/downloadService";
import * as storageService from "../services/storageService";
import {
extractBilibiliVideoId,
extractUrlFromText,
isBilibiliUrl,
isValidUrl,
resolveShortUrl,
trimBilibiliUrl
extractBilibiliVideoId,
extractUrlFromText,
isBilibiliUrl,
isValidUrl,
resolveShortUrl,
trimBilibiliUrl
} from "../utils/helpers";
// Configure Multer for file uploads
@@ -529,3 +529,94 @@ export const updateVideoDetails = (req: Request, res: Response): any => {
}
};
// Refresh video thumbnail
export const refreshThumbnail = async (req: Request, res: Response): Promise<any> => {
try {
const { id } = req.params;
const video = storageService.getVideoById(id);
if (!video) {
return res.status(404).json({ error: "Video not found" });
}
// Construct paths
let videoFilePath: string;
if (video.videoPath && video.videoPath.startsWith('/videos/')) {
const relativePath = video.videoPath.replace(/^\/videos\//, '');
// Split by / to handle the web path separators and join with system separator
videoFilePath = path.join(VIDEOS_DIR, ...relativePath.split('/'));
} else if (video.videoFilename) {
videoFilePath = path.join(VIDEOS_DIR, video.videoFilename);
} else {
return res.status(400).json({ error: "Video file path not found in record" });
}
if (!fs.existsSync(videoFilePath)) {
return res.status(404).json({ error: "Video file not found on disk" });
}
// Determine thumbnail path on disk
let thumbnailAbsolutePath: string;
let needsDbUpdate = false;
let newThumbnailFilename = video.thumbnailFilename;
let newThumbnailPath = video.thumbnailPath;
if (video.thumbnailPath && video.thumbnailPath.startsWith('/images/')) {
// Local file exists (or should exist) - preserve the existing path (e.g. inside a collection folder)
const relativePath = video.thumbnailPath.replace(/^\/images\//, '');
thumbnailAbsolutePath = path.join(IMAGES_DIR, ...relativePath.split('/'));
} else {
// Remote URL or missing - create a new local file in the root images directory
if (!newThumbnailFilename) {
const videoName = path.parse(path.basename(videoFilePath)).name;
newThumbnailFilename = `${videoName}.jpg`;
}
thumbnailAbsolutePath = path.join(IMAGES_DIR, newThumbnailFilename);
newThumbnailPath = `/images/${newThumbnailFilename}`;
needsDbUpdate = true;
}
// Ensure directory exists
fs.ensureDirSync(path.dirname(thumbnailAbsolutePath));
// Generate thumbnail
await new Promise<void>((resolve, reject) => {
// -y to overwrite existing file
exec(`ffmpeg -i "${videoFilePath}" -ss 00:00:00 -vframes 1 "${thumbnailAbsolutePath}" -y`, (error) => {
if (error) {
console.error("Error generating thumbnail:", error);
reject(error);
} else {
resolve();
}
});
});
// Update video record if needed (switching from remote to local, or creating new)
if (needsDbUpdate) {
const updates: any = {
thumbnailFilename: newThumbnailFilename,
thumbnailPath: newThumbnailPath,
thumbnailUrl: newThumbnailPath
};
storageService.updateVideo(id, updates);
}
// Return success with timestamp to bust cache
const thumbnailUrl = `${newThumbnailPath}?t=${Date.now()}`;
res.status(200).json({
success: true,
message: "Thumbnail refreshed successfully",
thumbnailUrl: thumbnailUrl
});
} catch (error: any) {
console.error("Error refreshing thumbnail:", error);
res.status(500).json({
error: "Failed to refresh thumbnail",
details: error.message
});
}
};

View File

@@ -15,6 +15,7 @@ router.put("/videos/:id", videoController.updateVideoDetails);
router.delete("/videos/:id", videoController.deleteVideo);
router.get("/videos/:id/comments", videoController.getVideoComments);
router.post("/videos/:id/rate", videoController.rateVideo);
router.post("/videos/:id/refresh-thumbnail", videoController.refreshThumbnail);
router.post("/scan-files", scanController.scanFiles);

View File

@@ -1,12 +1,12 @@
{
"name": "frontend",
"version": "0.0.0",
"version": "1.0.1",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "frontend",
"version": "0.0.0",
"version": "1.0.1",
"dependencies": {
"@emotion/react": "^11.14.0",
"@emotion/styled": "^11.14.1",

View File

@@ -1,6 +1,7 @@
import axios from 'axios';
import React, { createContext, useContext, useEffect, useRef, useState } from 'react';
import { Video } from '../types';
import { useLanguage } from './LanguageContext';
import { useSnackbar } from './SnackbarContext';
const API_URL = import.meta.env.VITE_API_URL;
@@ -11,6 +12,8 @@ interface VideoContextType {
error: string | null;
fetchVideos: () => Promise<void>;
deleteVideo: (id: string) => Promise<{ success: boolean; error?: string }>;
updateVideo: (id: string, updates: Partial<Video>) => Promise<{ success: boolean; error?: string }>;
refreshThumbnail: (id: string) => Promise<{ success: boolean; error?: string }>;
searchLocalVideos: (query: string) => Video[];
searchResults: any[];
localSearchResults: Video[];
@@ -35,6 +38,7 @@ export const useVideo = () => {
export const VideoProvider: React.FC<{ children: React.ReactNode }> = ({ children }) => {
const { showSnackbar } = useSnackbar();
const { t } = useLanguage();
const [videos, setVideos] = useState<Video[]>([]);
const [loading, setLoading] = useState<boolean>(true);
const [error, setError] = useState<string | null>(null);
@@ -57,7 +61,7 @@ export const VideoProvider: React.FC<{ children: React.ReactNode }> = ({ childre
setError(null);
} catch (err) {
console.error('Error fetching videos:', err);
setError('Failed to load videos. Please try again later.');
setError(t('failedToLoadVideos'));
} finally {
setLoading(false);
}
@@ -69,12 +73,12 @@ export const VideoProvider: React.FC<{ children: React.ReactNode }> = ({ childre
await axios.delete(`${API_URL}/videos/${id}`);
setVideos(prevVideos => prevVideos.filter(video => video.id !== id));
setLoading(false);
showSnackbar('Video removed successfully');
showSnackbar(t('videoRemovedSuccessfully'));
return { success: true };
} catch (error) {
console.error('Error deleting video:', error);
setLoading(false);
return { success: false, error: 'Failed to delete video' };
return { success: false, error: t('failedToDeleteVideo') };
}
};
@@ -102,7 +106,7 @@ export const VideoProvider: React.FC<{ children: React.ReactNode }> = ({ childre
const handleSearch = async (query: string): Promise<any> => {
if (!query || query.trim() === '') {
resetSearch();
return { success: false, error: 'Please enter a search term' };
return { success: false, error: t('pleaseEnterSearchTerm') };
}
try {
@@ -151,9 +155,9 @@ export const VideoProvider: React.FC<{ children: React.ReactNode }> = ({ childre
setSearchTerm(query);
return { success: true };
}
return { success: false, error: 'Failed to search. Please try again.' };
return { success: false, error: t('failedToSearch') };
}
return { success: false, error: 'Search was cancelled' };
return { success: false, error: t('searchCancelled') };
} finally {
if (searchAbortController.current && !searchAbortController.current.signal.aborted) {
setLoading(false);
@@ -176,6 +180,42 @@ export const VideoProvider: React.FC<{ children: React.ReactNode }> = ({ childre
};
}, []);
const refreshThumbnail = async (id: string) => {
try {
const response = await axios.post(`${API_URL}/videos/${id}/refresh-thumbnail`);
if (response.data.success) {
setVideos(prevVideos => prevVideos.map(video =>
video.id === id
? { ...video, thumbnailUrl: response.data.thumbnailUrl, thumbnailPath: response.data.thumbnailUrl }
: video
));
showSnackbar(t('thumbnailRefreshed'));
return { success: true };
}
return { success: false, error: t('thumbnailRefreshFailed') };
} catch (error) {
console.error('Error refreshing thumbnail:', error);
return { success: false, error: t('thumbnailRefreshFailed') };
}
};
const updateVideo = async (id: string, updates: Partial<Video>) => {
try {
const response = await axios.put(`${API_URL}/videos/${id}`, updates);
if (response.data.success) {
setVideos(prevVideos => prevVideos.map(video =>
video.id === id ? { ...video, ...updates } : video
));
showSnackbar(t('videoUpdated'));
return { success: true };
}
return { success: false, error: t('videoUpdateFailed') };
} catch (error) {
console.error('Error updating video:', error);
return { success: false, error: t('videoUpdateFailed') };
}
};
return (
<VideoContext.Provider value={{
videos,
@@ -183,6 +223,8 @@ export const VideoProvider: React.FC<{ children: React.ReactNode }> = ({ childre
error,
fetchVideos,
deleteVideo,
updateVideo,
refreshThumbnail,
searchLocalVideos,
searchResults,
localSearchResults,

View File

@@ -1,7 +1,11 @@
import {
ArrowBack,
Check,
Close,
Delete,
Edit,
Folder,
Refresh,
Search,
VideoLibrary
} from '@mui/icons-material';
@@ -30,6 +34,7 @@ import { Link } from 'react-router-dom';
import ConfirmationModal from '../components/ConfirmationModal';
import DeleteCollectionModal from '../components/DeleteCollectionModal';
import { useLanguage } from '../contexts/LanguageContext';
import { useVideo } from '../contexts/VideoContext';
import { Collection, Video } from '../types';
const BACKEND_URL = import.meta.env.VITE_BACKEND_URL;
@@ -44,12 +49,19 @@ interface ManagePageProps {
const ManagePage: React.FC<ManagePageProps> = ({ videos, onDeleteVideo, collections = [], onDeleteCollection }) => {
const [searchTerm, setSearchTerm] = useState<string>('');
const { t } = useLanguage();
const { refreshThumbnail, updateVideo } = useVideo();
const [deletingId, setDeletingId] = useState<string | null>(null);
const [refreshingId, setRefreshingId] = useState<string | null>(null);
const [collectionToDelete, setCollectionToDelete] = useState<Collection | null>(null);
const [isDeletingCollection, setIsDeletingCollection] = useState<boolean>(false);
const [videoToDelete, setVideoToDelete] = useState<string | null>(null);
const [showVideoDeleteModal, setShowVideoDeleteModal] = useState<boolean>(false);
// Editing state
const [editingVideoId, setEditingVideoId] = useState<string | null>(null);
const [editTitle, setEditTitle] = useState<string>('');
const [isSavingTitle, setIsSavingTitle] = useState<boolean>(false);
// Pagination state
const [collectionPage, setCollectionPage] = useState(1);
const [videoPage, setVideoPage] = useState(1);
@@ -117,6 +129,32 @@ const ManagePage: React.FC<ManagePageProps> = ({ videos, onDeleteVideo, collecti
setCollectionToDelete(null);
};
const handleRefreshThumbnail = async (id: string) => {
setRefreshingId(id);
await refreshThumbnail(id);
setRefreshingId(null);
};
const handleEditClick = (video: Video) => {
setEditingVideoId(video.id);
setEditTitle(video.title);
};
const handleCancelEdit = () => {
setEditingVideoId(null);
setEditTitle('');
};
const handleSaveTitle = async (id: string) => {
if (!editTitle.trim()) return;
setIsSavingTitle(true);
await updateVideo(id, { title: editTitle });
setIsSavingTitle(false);
setEditingVideoId(null);
setEditTitle('');
};
const getThumbnailSrc = (video: Video) => {
if (video.thumbnailPath) {
return `${BACKEND_URL}${video.thumbnailPath}`;
@@ -259,15 +297,90 @@ const ManagePage: React.FC<ManagePageProps> = ({ videos, onDeleteVideo, collecti
{displayedVideos.map(video => (
<TableRow key={video.id} hover>
<TableCell sx={{ width: 140 }}>
<Box
component="img"
src={getThumbnailSrc(video)}
alt={video.title}
sx={{ width: 120, height: 68, objectFit: 'cover', borderRadius: 1 }}
/>
<Box sx={{ position: 'relative', width: 120, height: 68 }}>
<Box
component="img"
src={getThumbnailSrc(video)}
alt={video.title}
sx={{ width: '100%', height: '100%', objectFit: 'cover', borderRadius: 1 }}
/>
<Tooltip title={t('refreshThumbnail') || "Refresh Thumbnail"}>
<IconButton
size="small"
onClick={() => handleRefreshThumbnail(video.id)}
disabled={refreshingId === video.id}
sx={{
position: 'absolute',
top: 0,
right: 0,
bgcolor: 'rgba(0,0,0,0.5)',
color: 'white',
'&:hover': { bgcolor: 'rgba(0,0,0,0.7)' },
p: 0.5,
width: 24,
height: 24
}}
>
{refreshingId === video.id ? <CircularProgress size={14} color="inherit" /> : <Refresh sx={{ fontSize: 16 }} />}
</IconButton>
</Tooltip>
</Box>
</TableCell>
<TableCell sx={{ fontWeight: 500 }}>
{video.title}
<TableCell sx={{ fontWeight: 500, maxWidth: 400 }}>
{editingVideoId === video.id ? (
<Box sx={{ display: 'flex', alignItems: 'center' }}>
<TextField
value={editTitle}
onChange={(e) => setEditTitle(e.target.value)}
size="small"
fullWidth
autoFocus
onKeyDown={(e) => {
if (e.key === 'Enter') handleSaveTitle(video.id);
if (e.key === 'Escape') handleCancelEdit();
}}
/>
<IconButton
size="small"
color="primary"
onClick={() => handleSaveTitle(video.id)}
disabled={isSavingTitle}
>
{isSavingTitle ? <CircularProgress size={20} /> : <Check />}
</IconButton>
<IconButton
size="small"
color="error"
onClick={handleCancelEdit}
disabled={isSavingTitle}
>
<Close />
</IconButton>
</Box>
) : (
<Box sx={{ display: 'flex', alignItems: 'flex-start' }}>
<IconButton
size="small"
onClick={() => handleEditClick(video)}
sx={{ mr: 1, mt: -0.5, opacity: 0.6, '&:hover': { opacity: 1 } }}
>
<Edit fontSize="small" />
</IconButton>
<Typography
variant="body2"
sx={{
display: '-webkit-box',
WebkitLineClamp: 2,
WebkitBoxOrient: 'vertical',
overflow: 'hidden',
textOverflow: 'ellipsis',
lineHeight: 1.4
}}
>
{video.title}
</Typography>
</Box>
)}
</TableCell>
<TableCell>{video.author}</TableCell>
<TableCell align="right">

View File

@@ -140,6 +140,17 @@ export const ar = {
editTitle: "تعديل العنوان",
titleUpdated: "تم تحديث العنوان بنجاح",
titleUpdateFailed: "فشل تحديث العنوان",
refreshThumbnail: "تحديث الصورة المصغرة",
thumbnailRefreshed: "تم تحديث الصورة المصغرة بنجاح",
thumbnailRefreshFailed: "فشل تحديث الصورة المصغرة",
videoUpdated: "تم تحديث الفيديو بنجاح",
videoUpdateFailed: "فشل تحديث الفيديو",
failedToLoadVideos: "فشل تحميل مقاطع الفيديو. يرجى المحاولة مرة أخرى لاحقًا.",
videoRemovedSuccessfully: "تم حذف الفيديو بنجاح",
failedToDeleteVideo: "فشل حذف الفيديو",
pleaseEnterSearchTerm: "الرجاء إدخال مصطلح البحث",
failedToSearch: "فشل البحث. يرجى المحاولة مرة أخرى.",
searchCancelled: "تم إلغاء البحث",
// Login
signIn: "تسجيل الدخول",

View File

@@ -26,7 +26,9 @@ export const de = {
migrateConfirmation: "Sind Sie sicher, dass Sie Daten migrieren möchten? Dies kann einige Momente dauern.",
migrationResults: "Migrationsergebnisse", migrationReport: "Migrationsbericht",
migrationSuccess: "Migration abgeschlossen. Details in der Warnung anzeigen.", migrationNoData: "Migration abgeschlossen, aber keine Daten gefunden.",
migrationFailed: "Migration fehlgeschlagen", migrationWarnings: "WARNUNGEN", migrationErrors: "FEHLER",
migrationFailed: "Migration fehlgeschlagen",
migrationWarnings: "WARNUNGEN", migrationErrors: "FEHLER",
itemsMigrated: "Elemente migriert", fileNotFound: "Datei nicht gefunden unter",
noDataFilesFound: "Keine Datendateien zum Migrieren gefunden. Bitte überprüfen Sie Ihre Volume-Zuordnungen.",
removeLegacyData: "Legacy-Daten Entfernen", removeLegacyDataDescription: "Löschen Sie die alten JSON-Dateien, um Speicherplatz freizugeben. Tun Sie dies nur, nachdem Sie überprüft haben, dass Ihre Daten erfolgreich migriert wurden.",
@@ -52,6 +54,17 @@ export const de = {
loadingVideo: "Video wird geladen...", current: "(Aktuell)", rateThisVideo: "Dieses Video bewerten",
enterFullscreen: "Vollbild", exitFullscreen: "Vollbild Verlassen", editTitle: "Titel Bearbeiten",
titleUpdated: "Titel erfolgreich aktualisiert", titleUpdateFailed: "Fehler beim Aktualisieren des Titels",
refreshThumbnail: "Vorschaubild aktualisieren",
thumbnailRefreshed: "Vorschaubild erfolgreich aktualisiert",
thumbnailRefreshFailed: "Aktualisierung des Vorschaubilds fehlgeschlagen",
videoUpdated: "Video erfolgreich aktualisiert",
videoUpdateFailed: "Videoaktualisierung fehlgeschlagen",
failedToLoadVideos: "Videos konnten nicht geladen werden. Bitte versuchen Sie es später erneut.",
videoRemovedSuccessfully: "Video erfolgreich entfernt",
failedToDeleteVideo: "Löschen des Videos fehlgeschlagen",
pleaseEnterSearchTerm: "Bitte geben Sie einen Suchbegriff ein",
failedToSearch: "Suche fehlgeschlagen. Bitte versuchen Sie es erneut.",
searchCancelled: "Suche abgebrochen",
signIn: "Anmelden", verifying: "Überprüfen...", incorrectPassword: "Falsches Passwort",
loginFailed: "Fehler beim Überprüfen des Passworts", defaultPasswordHint: "Standardpasswort: 123",
loadingCollection: "Sammlung wird geladen...", collectionNotFound: "Sammlung nicht gefunden",

View File

@@ -101,6 +101,7 @@ export const en = {
deleteCollection: "Delete Collection",
deleteVideo: "Delete Video",
noVideosFoundMatching: "No videos found matching your search.",
refreshThumbnail: "Refresh Thumbnail",
// Video Player
playing: "Play",
@@ -140,6 +141,16 @@ export const en = {
editTitle: "Edit Title",
titleUpdated: "Title updated successfully",
titleUpdateFailed: "Failed to update title",
thumbnailRefreshed: "Thumbnail refreshed successfully",
thumbnailRefreshFailed: "Failed to refresh thumbnail",
videoUpdated: "Video updated successfully",
videoUpdateFailed: "Failed to update video",
failedToLoadVideos: "Failed to load videos. Please try again later.",
videoRemovedSuccessfully: "Video removed successfully",
failedToDeleteVideo: "Failed to delete video",
pleaseEnterSearchTerm: "Please enter a search term",
failedToSearch: "Failed to search. Please try again.",
searchCancelled: "Search was cancelled",
// Login
signIn: "Sign in",

View File

@@ -51,7 +51,18 @@ export const es = {
confirmRemoveFromCollection: "¿Está seguro de que desea eliminar este video de la colección?", remove: "Eliminar",
loadingVideo: "Cargando video...", current: "(Actual)", rateThisVideo: "Calificar este video",
enterFullscreen: "Pantalla Completa", exitFullscreen: "Salir de Pantalla Completa", editTitle: "Editar Título",
titleUpdated: "Título actualizado exitosamente", titleUpdateFailed: "Error al actualizar el título",
titleUpdated: "Título actualizado exitosamente", titleUpdateFailed: "Error al actualizar el título",
refreshThumbnail: "Actualizar miniatura",
thumbnailRefreshed: "Miniatura actualizada con éxito",
thumbnailRefreshFailed: "Error al actualizar la miniatura",
videoUpdated: "Video actualizado con éxito",
videoUpdateFailed: "Error al actualizar el video",
failedToLoadVideos: "Error al cargar videos. Por favor, inténtelo de nuevo más tarde.",
videoRemovedSuccessfully: "Video eliminado con éxito",
failedToDeleteVideo: "Error al eliminar el video",
pleaseEnterSearchTerm: "Por favor, introduzca un término de búsqueda",
failedToSearch: "Error en la búsqueda. Por favor, inténtelo de nuevo.",
searchCancelled: "Búsqueda cancelada",
signIn: "Iniciar Sesión", verifying: "Verificando...", incorrectPassword: "Contraseña incorrecta",
loginFailed: "Error al verificar la contraseña", defaultPasswordHint: "Contraseña predeterminada: 123",
loadingCollection: "Cargando colección...", collectionNotFound: "Colección no encontrada",

View File

@@ -101,6 +101,7 @@ export const fr = {
deleteCollection: "Supprimer la collection",
deleteVideo: "Supprimer la vidéo",
noVideosFoundMatching: "Aucune vidéo ne correspond à votre recherche.",
refreshThumbnail: "Actualiser la miniature",
// Video Player
playing: "Lecture",
@@ -140,6 +141,16 @@ export const fr = {
editTitle: "Modifier le titre",
titleUpdated: "Titre mis à jour avec succès",
titleUpdateFailed: "Échec de la mise à jour du titre",
thumbnailRefreshed: "Miniature actualisée avec succès",
thumbnailRefreshFailed: "Échec de l'actualisation de la miniature",
videoUpdated: "Vidéo mise à jour avec succès",
videoUpdateFailed: "Échec de la mise à jour de la vidéo",
failedToLoadVideos: "Échec du chargement des vidéos. Veuillez réessayer plus tard.",
videoRemovedSuccessfully: "Vidéo supprimée avec succès",
failedToDeleteVideo: "Échec de la suppression de la vidéo",
pleaseEnterSearchTerm: "Veuillez entrer un terme de recherche",
failedToSearch: "Échec de la recherche. Veuillez réessayer.",
searchCancelled: "Recherche annulée",
// Login
signIn: "Se connecter",

View File

@@ -140,6 +140,17 @@ export const ja = {
editTitle: "タイトルを編集",
titleUpdated: "タイトルが正常に更新されました",
titleUpdateFailed: "タイトルの更新に失敗しました",
refreshThumbnail: "サムネイルを更新",
thumbnailRefreshed: "サムネイルを更新しました",
thumbnailRefreshFailed: "サムネイルの更新に失敗しました",
videoUpdated: "動画を更新しました",
videoUpdateFailed: "動画の更新に失敗しました",
failedToLoadVideos: "動画の読み込みに失敗しました。後でもう一度お試しください。",
videoRemovedSuccessfully: "動画を削除しました",
failedToDeleteVideo: "動画の削除に失敗しました",
pleaseEnterSearchTerm: "検索語を入力してください",
failedToSearch: "検索に失敗しました。もう一度お試しください。",
searchCancelled: "検索がキャンセルされました",
// Login
signIn: "サインイン",

View File

@@ -140,6 +140,17 @@ export const ko = {
editTitle: "제목 편집",
titleUpdated: "제목이 성공적으로 업데이트됨",
titleUpdateFailed: "제목 업데이트 실패",
refreshThumbnail: "썸네일 새로고침",
thumbnailRefreshed: "썸네일이 새로고침되었습니다",
thumbnailRefreshFailed: "썸네일 새로고침 실패",
videoUpdated: "비디오가 업데이트되었습니다",
videoUpdateFailed: "비디오 업데이트 실패",
failedToLoadVideos: "비디오를 불러오지 못했습니다. 나중에 다시 시도해주세요.",
videoRemovedSuccessfully: "비디오가 삭제되었습니다",
failedToDeleteVideo: "비디오 삭제 실패",
pleaseEnterSearchTerm: "검색어를 입력해주세요",
failedToSearch: "검색 실패. 다시 시도해주세요.",
searchCancelled: "검색이 취소되었습니다",
// Login
signIn: "로그인",

View File

@@ -140,6 +140,17 @@ export const pt = {
editTitle: "Editar Título",
titleUpdated: "Título atualizado com sucesso",
titleUpdateFailed: "Falha ao atualizar título",
refreshThumbnail: "Atualizar miniatura",
thumbnailRefreshed: "Miniatura atualizada com sucesso",
thumbnailRefreshFailed: "Falha ao atualizar miniatura",
videoUpdated: "Vídeo atualizado com sucesso",
videoUpdateFailed: "Falha ao atualizar vídeo",
failedToLoadVideos: "Falha ao carregar vídeos. Por favor, tente novamente mais tarde.",
videoRemovedSuccessfully: "Vídeo removido com sucesso",
failedToDeleteVideo: "Falha ao remover vídeo",
pleaseEnterSearchTerm: "Por favor, insira um termo de pesquisa",
failedToSearch: "Falha na pesquisa. Por favor, tente novamente.",
searchCancelled: "Pesquisa cancelada",
// Login
signIn: "Entrar",

View File

@@ -140,6 +140,17 @@ export const ru = {
editTitle: "Редактировать название",
titleUpdated: "Название успешно обновлено",
titleUpdateFailed: "Не удалось обновить название",
refreshThumbnail: "Обновить миниатюру",
thumbnailRefreshed: "Миниатюра успешно обновлена",
thumbnailRefreshFailed: "Не удалось обновить миниатюру",
videoUpdated: "Видео успешно обновлено",
videoUpdateFailed: "Не удалось обновить видео",
failedToLoadVideos: "Не удалось загрузить видео. Пожалуйста, попробуйте позже.",
videoRemovedSuccessfully: "Видео успешно удалено",
failedToDeleteVideo: "Не удалось удалить видео",
pleaseEnterSearchTerm: "Пожалуйста, введите поисковый запрос",
failedToSearch: "Поиск не удался. Пожалуйста, попробуйте снова.",
searchCancelled: "Поиск отменен",
// Login
signIn: "Войти",

View File

@@ -140,6 +140,17 @@ export const zh = {
editTitle: "编辑标题",
titleUpdated: "标题更新成功",
titleUpdateFailed: "更新标题失败",
refreshThumbnail: "刷新缩略图",
thumbnailRefreshed: "缩略图刷新成功",
thumbnailRefreshFailed: "刷新缩略图失败",
videoUpdated: "视频更新成功",
videoUpdateFailed: "更新视频失败",
failedToLoadVideos: "加载视频失败。请稍后再试。",
videoRemovedSuccessfully: "视频删除成功",
failedToDeleteVideo: "删除视频失败",
pleaseEnterSearchTerm: "请输入搜索词",
failedToSearch: "搜索失败。请稍后再试。",
searchCancelled: "搜索已取消",
// Login
signIn: "登录",

4
package-lock.json generated
View File

@@ -1,12 +1,12 @@
{
"name": "mytube",
"version": "1.1.0",
"version": "1.0.1",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "mytube",
"version": "1.1.0",
"version": "1.0.1",
"license": "MIT",
"dependencies": {
"concurrently": "^8.2.2"