feat: Add tags support to videos and implement tag management

This commit is contained in:
Peifan Li
2025-11-25 17:29:36 -05:00
parent 27795954a3
commit f0568e8934
11 changed files with 296 additions and 13 deletions

View File

@@ -505,6 +505,7 @@ export const updateVideoDetails = (req: Request, res: Response): any => {
// Filter allowed updates
const allowedUpdates: any = {};
if (updates.title !== undefined) allowedUpdates.title = updates.title;
if (updates.tags !== undefined) allowedUpdates.tags = updates.tags;
// Add other allowed fields here if needed in the future
if (Object.keys(allowedUpdates).length === 0) {

View File

@@ -9,6 +9,6 @@ import * as schema from './schema';
fs.ensureDirSync(DATA_DIR);
const dbPath = path.join(DATA_DIR, 'mytube.db');
const sqlite = new Database(dbPath);
export const sqlite = new Database(dbPath);
export const db = drizzle(sqlite, { schema });

View File

@@ -24,6 +24,7 @@ export const videos = sqliteTable('videos', {
description: text('description'),
viewCount: integer('view_count'),
duration: text('duration'),
tags: text('tags'), // JSON stringified array of strings
});
export const collections = sqliteTable('collections', {

View File

@@ -8,7 +8,7 @@ import {
UPLOADS_DIR,
VIDEOS_DIR,
} from "../config/paths";
import { db } from "../db";
import { db, sqlite } from "../db";
import { collections, collectionVideos, downloads, settings, videos } from "../db/schema";
export interface Video {
@@ -18,6 +18,7 @@ export interface Video {
videoFilename?: string;
thumbnailFilename?: string;
createdAt: string;
tags?: string[];
[key: string]: any;
}
@@ -74,6 +75,20 @@ export function initializeStorage(): void {
);
}
}
// Check and migrate tags column if needed
try {
const tableInfo = sqlite.prepare("PRAGMA table_info(videos)").all();
const hasTags = (tableInfo as any[]).some((col: any) => col.name === 'tags');
if (!hasTags) {
console.log("Migrating database: Adding tags column to videos table...");
sqlite.prepare("ALTER TABLE videos ADD COLUMN tags TEXT").run();
console.log("Migration successful.");
}
} catch (error) {
console.error("Error checking/migrating tags column:", error);
}
}
@@ -240,7 +255,10 @@ export function saveSettings(newSettings: Record<string, any>): void {
export function getVideos(): Video[] {
try {
const allVideos = db.select().from(videos).orderBy(desc(videos.createdAt)).all();
return allVideos as Video[];
return allVideos.map(v => ({
...v,
tags: v.tags ? JSON.parse(v.tags) : [],
})) as Video[];
} catch (error) {
console.error("Error getting videos:", error);
return [];
@@ -250,7 +268,13 @@ export function getVideos(): Video[] {
export function getVideoById(id: string): Video | undefined {
try {
const video = db.select().from(videos).where(eq(videos.id, id)).get();
return video as Video | undefined;
if (video) {
return {
...video,
tags: video.tags ? JSON.parse(video.tags) : [],
} as Video;
}
return undefined;
} catch (error) {
console.error("Error getting video by id:", error);
return undefined;
@@ -259,9 +283,13 @@ export function getVideoById(id: string): Video | undefined {
export function saveVideo(videoData: Video): Video {
try {
db.insert(videos).values(videoData as any).onConflictDoUpdate({
const videoToSave = {
...videoData,
tags: videoData.tags ? JSON.stringify(videoData.tags) : undefined,
};
db.insert(videos).values(videoToSave as any).onConflictDoUpdate({
target: videos.id,
set: videoData,
set: videoToSave,
}).run();
return videoData;
} catch (error) {
@@ -272,8 +300,22 @@ export function saveVideo(videoData: Video): Video {
export function updateVideo(id: string, updates: Partial<Video>): Video | null {
try {
const result = db.update(videos).set(updates as any).where(eq(videos.id, id)).returning().get();
return (result as Video) || null;
const updatesToSave = {
...updates,
tags: updates.tags ? JSON.stringify(updates.tags) : undefined,
};
// If tags is explicitly empty array, we might want to save it as '[]' or null.
// JSON.stringify([]) is '[]', which is fine.
const result = db.update(videos).set(updatesToSave as any).where(eq(videos.id, id)).returning().get();
if (result) {
return {
...result,
tags: result.tags ? JSON.parse(result.tags) : [],
} as Video;
}
return null;
} catch (error) {
console.error("Error updating video:", error);
return null;

View File

@@ -777,7 +777,7 @@ function App() {
type={bilibiliPartsInfo.type}
/>
<Box component="main" sx={{ flex: 1, display: 'flex', flexDirection: 'column', width: '100%', px: { xs: 2, md: 4, lg: 8 } }}>
<Box component="main" sx={{ flex: 1, display: 'flex', flexDirection: 'column', width: '100%', px: { xs: 1, md: 2, lg: 4 } }}>
<AnimatedRoutes
videos={videos}
loading={loading}

View File

@@ -0,0 +1,76 @@
import { ExpandLess, ExpandMore, LocalOffer } from '@mui/icons-material';
import {
Box,
Chip,
Collapse,
ListItemButton,
Paper,
Typography,
useMediaQuery,
useTheme
} from '@mui/material';
import { useEffect, useState } from 'react';
import { useLanguage } from '../contexts/LanguageContext';
interface TagsListProps {
availableTags: string[];
selectedTags: string[];
onTagToggle: (tag: string) => void;
}
const TagsList: React.FC<TagsListProps> = ({ availableTags, selectedTags, onTagToggle }) => {
const { t } = useLanguage();
const [isOpen, setIsOpen] = useState<boolean>(true);
const theme = useTheme();
const isMobile = useMediaQuery(theme.breakpoints.down('md'));
// Auto-collapse on mobile by default
useEffect(() => {
if (isMobile) {
setIsOpen(false);
} else {
setIsOpen(true);
}
}, [isMobile]);
if (!availableTags || availableTags.length === 0) {
return null;
}
return (
<Paper elevation={0} sx={{ bgcolor: 'transparent' }}>
<ListItemButton onClick={() => setIsOpen(!isOpen)} sx={{ borderRadius: 1, mb: 1 }}>
<Typography variant="h6" component="div" sx={{ flexGrow: 1, fontWeight: 600 }}>
{t('tags') || 'Tags'}
</Typography>
{isOpen ? <ExpandLess /> : <ExpandMore />}
</ListItemButton>
<Collapse in={isOpen} timeout="auto" unmountOnExit>
<Box sx={{ display: 'flex', flexWrap: 'wrap', gap: 1, px: 2, pb: 2 }}>
{availableTags.map(tag => {
const isSelected = selectedTags.includes(tag);
return (
<Chip
key={tag}
label={tag}
onClick={() => onTagToggle(tag)}
color={isSelected ? "primary" : "default"}
variant={isSelected ? "filled" : "outlined"}
icon={isSelected ? <LocalOffer sx={{ fontSize: '1rem !important' }} /> : undefined}
sx={{
cursor: 'pointer',
transition: 'all 0.2s',
'&:hover': {
bgcolor: isSelected ? 'primary.dark' : 'action.hover'
}
}}
/>
);
})}
</Box>
</Collapse>
</Paper>
);
};
export default TagsList;

View File

@@ -14,10 +14,12 @@ import {
Pagination,
Typography
} from '@mui/material';
import axios from 'axios';
import { useEffect, useState } from 'react';
import AuthorsList from '../components/AuthorsList';
import CollectionCard from '../components/CollectionCard';
import Collections from '../components/Collections';
import TagsList from '../components/TagsList';
import VideoCard from '../components/VideoCard';
import { useLanguage } from '../contexts/LanguageContext';
import { Collection, Video } from '../types';
@@ -62,9 +64,27 @@ const Home: React.FC<HomeProps> = ({
onDownload,
onResetSearch
}) => {
const API_URL = import.meta.env.VITE_API_URL;
const [page, setPage] = useState(1);
const ITEMS_PER_PAGE = 12;
const { t } = useLanguage();
const [availableTags, setAvailableTags] = useState<string[]>([]);
const [selectedTags, setSelectedTags] = useState<string[]>([]);
// Fetch tags
useEffect(() => {
const fetchTags = async () => {
try {
const response = await axios.get(`${API_URL}/settings`);
if (response.data.tags) {
setAvailableTags(response.data.tags);
}
} catch (error) {
console.error('Error fetching tags:', error);
}
};
fetchTags();
}, []);
// Reset page when filters change (though currently no filters other than search which is separate)
useEffect(() => {
@@ -94,6 +114,13 @@ const Home: React.FC<HomeProps> = ({
// Filter videos to only show the first video from each collection
const filteredVideos = videoArray.filter(video => {
// Tag filtering
if (selectedTags.length > 0) {
const videoTags = video.tags || [];
const hasMatchingTag = selectedTags.every(tag => videoTags.includes(tag));
if (!hasMatchingTag) return false;
}
// If the video is not in any collection, show it
const videoCollections = collections.filter(collection =>
collection.videos.includes(video.id)
@@ -112,6 +139,15 @@ const Home: React.FC<HomeProps> = ({
});
});
const handleTagToggle = (tag: string) => {
setSelectedTags(prev =>
prev.includes(tag)
? prev.filter(t => t !== tag)
: [...prev, tag]
);
setPage(1); // Reset to first page when filter changes
};
// Pagination logic
const totalPages = Math.ceil(filteredVideos.length / ITEMS_PER_PAGE);
const displayedVideos = filteredVideos.slice(
@@ -119,7 +155,7 @@ const Home: React.FC<HomeProps> = ({
page * ITEMS_PER_PAGE
);
const handlePageChange = (event: React.ChangeEvent<unknown>, value: number) => {
const handlePageChange = (_: React.ChangeEvent<unknown>, value: number) => {
setPage(value);
window.scrollTo({ top: 0, behavior: 'smooth' });
};
@@ -272,10 +308,17 @@ const Home: React.FC<HomeProps> = ({
</Box>
) : (
<Grid container spacing={4}>
{/* Sidebar container for Collections and Authors */}
{/* Sidebar container for Collections, Authors, and Tags */}
<Grid size={{ xs: 12, md: 3 }} sx={{ display: { xs: 'none', md: 'block' } }}>
<Box sx={{ position: 'sticky', top: 80 }}>
<Collections collections={collections} />
<Box sx={{ mt: 2 }}>
<TagsList
availableTags={availableTags}
selectedTags={selectedTags}
onTagToggle={handleTagToggle}
/>
</Box>
<Box sx={{ mt: 2 }}>
<AuthorsList videos={videoArray} />
</Box>

View File

@@ -8,6 +8,7 @@ import {
Button,
Card,
CardContent,
Chip,
Container,
Divider,
FormControl,
@@ -40,6 +41,7 @@ interface Settings {
defaultAutoLoop: boolean;
maxConcurrentDownloads: number;
language: string;
tags: string[];
}
const SettingsPage: React.FC = () => {
@@ -49,8 +51,10 @@ const SettingsPage: React.FC = () => {
defaultAutoPlay: false,
defaultAutoLoop: false,
maxConcurrentDownloads: 3,
language: 'en'
language: 'en',
tags: []
});
const [newTag, setNewTag] = useState('');
const [saving, setSaving] = useState(false);
const [message, setMessage] = useState<{ text: string; type: 'success' | 'error' | 'warning' | 'info' } | null>(null);
@@ -75,7 +79,10 @@ const SettingsPage: React.FC = () => {
const fetchSettings = async () => {
try {
const response = await axios.get(`${API_URL}/settings`);
setSettings(response.data);
setSettings({
...response.data,
tags: response.data.tags || []
});
} catch (error) {
console.error('Error fetching settings:', error);
setMessage({ text: t('settingsFailed'), type: 'error' });
@@ -93,6 +100,7 @@ const SettingsPage: React.FC = () => {
delete settingsToSend.password;
}
console.log('Saving settings:', settingsToSend);
await axios.post(`${API_URL}/settings`, settingsToSend);
setMessage({ text: t('settingsSaved'), type: 'success' });
@@ -113,6 +121,19 @@ const SettingsPage: React.FC = () => {
}
};
const handleAddTag = () => {
if (newTag && !settings.tags.includes(newTag)) {
const updatedTags = [...settings.tags, newTag];
setSettings(prev => ({ ...prev, tags: updatedTags }));
setNewTag('');
}
};
const handleDeleteTag = (tagToDelete: string) => {
const updatedTags = settings.tags.filter(tag => tag !== tagToDelete);
setSettings(prev => ({ ...prev, tags: updatedTags }));
};
return (
<Container maxWidth="xl" sx={{ py: 4 }}>
<Box sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', mb: 4 }}>
@@ -214,6 +235,39 @@ const SettingsPage: React.FC = () => {
<Grid size={12}><Divider /></Grid>
{/* Tags Management */}
<Grid size={12}>
<Typography variant="h6" gutterBottom>{t('tagsManagement') || 'Tags Management'}</Typography>
<Box sx={{ display: 'flex', gap: 1, mb: 2, flexWrap: 'wrap' }}>
{settings.tags && settings.tags.map((tag) => (
<Chip
key={tag}
label={tag}
onDelete={() => handleDeleteTag(tag)}
/>
))}
</Box>
<Box sx={{ display: 'flex', gap: 1, maxWidth: 400 }}>
<TextField
label={t('newTag') || 'New Tag'}
value={newTag}
onChange={(e) => setNewTag(e.target.value)}
size="small"
fullWidth
onKeyPress={(e) => {
if (e.key === 'Enter') {
handleAddTag();
}
}}
/>
<Button variant="contained" onClick={handleAddTag}>
{t('add') || 'Add'}
</Button>
</Box>
</Grid>
<Grid size={12}><Divider /></Grid>
{/* Download Settings */}
<Grid size={12}>
<Typography variant="h6" gutterBottom>{t('downloadSettings')}</Typography>

View File

@@ -13,6 +13,7 @@ import {
Fullscreen,
FullscreenExit,
Link as LinkIcon,
LocalOffer,
Loop,
Pause,
PlayArrow,
@@ -21,6 +22,7 @@ import {
} from '@mui/icons-material';
import {
Alert,
Autocomplete,
Avatar,
Box,
Button,
@@ -96,6 +98,7 @@ const VideoPlayer: React.FC<VideoPlayerProps> = ({
const [loadingComments, setLoadingComments] = useState<boolean>(false);
const [showComments, setShowComments] = useState<boolean>(false);
const [commentsLoaded, setCommentsLoaded] = useState<boolean>(false);
const [availableTags, setAvailableTags] = useState<string[]>([]);
const videoRef = useRef<HTMLVideoElement>(null);
const [isPlaying, setIsPlaying] = useState<boolean>(false);
@@ -223,6 +226,10 @@ const VideoPlayer: React.FC<VideoPlayerProps> = ({
setIsLooping(true);
}
}
console.log('Fetched settings in VideoPlayer:', response.data);
setAvailableTags(response.data.tags || []);
console.log('Setting available tags:', response.data.tags || []);
} catch (error) {
console.error('Error fetching settings:', error);
}
@@ -423,6 +430,19 @@ const VideoPlayer: React.FC<VideoPlayerProps> = ({
}
};
const handleUpdateTags = async (newTags: string[]) => {
if (!id) return;
try {
const response = await axios.put(`${API_URL}/videos/${id}`, { tags: newTags });
if (response.data.success) {
setVideo(prev => prev ? { ...prev, tags: newTags } : null);
}
} catch (error) {
console.error('Error updating tags:', error);
showSnackbar(t('error'), 'error');
}
};
if (loading) {
return (
<Box sx={{ display: 'flex', justifyContent: 'center', alignItems: 'center', minHeight: '50vh' }}>
@@ -643,6 +663,7 @@ const VideoPlayer: React.FC<VideoPlayerProps> = ({
</Stack>
</Stack>
{deleteError && (
<Alert severity="error" sx={{ mb: 2 }}>
{deleteError}
@@ -681,6 +702,8 @@ const VideoPlayer: React.FC<VideoPlayerProps> = ({
)}
</Stack>
{videoCollections.length > 0 && (
<Box sx={{ mt: 2 }}>
<Typography variant="subtitle2" gutterBottom>{t('collections')}:</Typography>
@@ -702,6 +725,42 @@ const VideoPlayer: React.FC<VideoPlayerProps> = ({
)}
</Box>
{/* Tags Section */}
<Box sx={{ mb: 2, display: 'flex', alignItems: 'center', gap: 1 }}>
<LocalOffer color="action" fontSize="small" />
<Autocomplete
multiple
options={availableTags}
value={video.tags || []}
isOptionEqualToValue={(option, value) => option === value}
onChange={(_, newValue) => handleUpdateTags(newValue)}
renderInput={(params) => (
<TextField
{...params}
variant="standard"
placeholder={!video.tags || video.tags.length === 0 ? (t('tags') || 'Tags') : ''}
sx={{ minWidth: 200 }}
InputProps={{ ...params.InputProps, disableUnderline: true }}
/>
)}
renderTags={(value, getTagProps) =>
value.map((option, index) => {
const { key, ...tagProps } = getTagProps({ index });
return (
<Chip
key={key}
variant="outlined"
label={option}
size="small"
{...tagProps}
/>
);
})
}
sx={{ flexGrow: 1 }}
/>
</Box>
{/* Comments Section */}
<Box sx={{ mt: 4 }}>
<Box sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', mb: 2 }}>

View File

@@ -15,6 +15,7 @@ export interface Video {
totalParts?: number;
seriesTitle?: string;
rating?: number;
tags?: string[];
[key: string]: any;
}

View File

@@ -49,6 +49,9 @@ export const translations = {
settingsFailed: "Failed to save settings",
debugMode: "Debug Mode",
debugModeDescription: "Show or hide console messages (requires refresh)",
tagsManagement: "Tags Management",
newTag: "New Tag",
tags: "Tags",
// Database
database: "Database",
@@ -254,6 +257,9 @@ export const translations = {
settingsFailed: "保存设置失败",
debugMode: "调试模式",
debugModeDescription: "显示或隐藏控制台消息(需要刷新)",
tagsManagement: "标签管理",
newTag: "新标签",
tags: "标签",
// Database
database: "数据库",