feat(api): Add system controller and version check endpoint

This commit is contained in:
Peifan Li
2025-12-29 17:40:05 -05:00
parent 21c3f4c514
commit db3d917427
5 changed files with 162 additions and 3 deletions

View File

@@ -0,0 +1,99 @@
import axios from "axios";
import { Request, Response } from "express";
import { logger } from "../utils/logger";
import { VERSION } from "../version";
interface GithubRelease {
tag_name: string;
html_url: string;
body: string;
published_at: string;
}
// Helper to compare semantic versions (v1 > v2)
const isNewerVersion = (latest: string, current: string): boolean => {
try {
const v1 = latest.split('.').map(Number);
const v2 = current.split('.').map(Number);
for (let i = 0; i < Math.max(v1.length, v2.length); i++) {
const num1 = v1[i] || 0;
const num2 = v2[i] || 0;
if (num1 > num2) return true;
if (num1 < num2) return false;
}
return false;
} catch (e) {
// Fallback to string comparison if parsing fails
return latest !== current;
}
};
export const getLatestVersion = async (req: Request, res: Response) => {
try {
const currentVersion = VERSION.number;
const response = await axios.get<GithubRelease>(
"https://api.github.com/repos/franklioxygen/mytube/releases/latest",
{
headers: {
Accept: "application/vnd.github.v3+json",
"User-Agent": "MyTube-App",
},
timeout: 5000, // 5 second timeout
}
);
const latestVersion = response.data.tag_name.replace(/^v/, "");
const releaseUrl = response.data.html_url;
res.json({
currentVersion,
latestVersion,
releaseUrl,
hasUpdate: isNewerVersion(latestVersion, currentVersion),
});
} catch (error) {
if (axios.isAxiosError(error) && error.response?.status === 404) {
// Fallback: Try to get tags if no release is published
try {
const tagsResponse = await axios.get<any[]>(
"https://api.github.com/repos/franklioxygen/mytube/tags",
{
headers: {
Accept: "application/vnd.github.v3+json",
"User-Agent": "MyTube-App",
},
timeout: 5000,
}
);
if (tagsResponse.data && tagsResponse.data.length > 0) {
const latestTag = tagsResponse.data[0];
const latestVersion = latestTag.name.replace(/^v/, "");
const releaseUrl = `https://github.com/franklioxygen/mytube/releases/tag/${latestTag.name}`;
const currentVersion = VERSION.number;
return res.json({
currentVersion,
latestVersion,
releaseUrl,
hasUpdate: isNewerVersion(latestVersion, currentVersion),
});
}
} catch (tagError) {
logger.warn("Failed to fetch tags as fallback:", tagError);
}
}
logger.error("Failed to check for updates:", error);
// Return current version if check fails
res.json({
currentVersion: VERSION.number,
latestVersion: VERSION.number,
releaseUrl: "",
hasUpdate: false,
error: "Failed to check for updates",
});
}
};

View File

@@ -5,6 +5,7 @@ import * as collectionController from "../controllers/collectionController";
import * as downloadController from "../controllers/downloadController";
import * as scanController from "../controllers/scanController";
import * as subscriptionController from "../controllers/subscriptionController";
import * as systemController from "../controllers/systemController";
import * as videoController from "../controllers/videoController";
import * as videoDownloadController from "../controllers/videoDownloadController";
import * as videoMetadataController from "../controllers/videoMetadataController";
@@ -162,4 +163,7 @@ router.delete(
asyncHandler(cloudStorageController.clearThumbnailCacheEndpoint)
);
// System routes
router.get("/system/version", asyncHandler(systemController.getLatestVersion));
export default router;

View File

@@ -3,7 +3,7 @@
*/
export const VERSION = {
number: "1.1.0",
number: "1.7.15",
buildDate: new Date().toISOString().split("T")[0],
name: "MyTube Backend Server",
displayVersion: function () {

View File

@@ -6,6 +6,7 @@ import App from '../App';
// Mock axios
vi.mock('axios', () => ({
default: {
create: vi.fn(() => ({
get: vi.fn(() => Promise.resolve({ data: {} })),
post: vi.fn(() => Promise.resolve({ data: {} })),
put: vi.fn(() => Promise.resolve({ data: {} })),
@@ -13,7 +14,18 @@ vi.mock('axios', () => ({
interceptors: {
request: { use: vi.fn(), eject: vi.fn() },
response: { use: vi.fn(), eject: vi.fn() }
}
},
defaults: { headers: { common: {} } }
})),
get: vi.fn(() => Promise.resolve({ data: {} })),
post: vi.fn(() => Promise.resolve({ data: {} })),
put: vi.fn(() => Promise.resolve({ data: {} })),
delete: vi.fn(() => Promise.resolve({ data: {} })),
interceptors: {
request: { use: vi.fn(), eject: vi.fn() },
response: { use: vi.fn(), eject: vi.fn() }
},
isAxiosError: vi.fn(() => false)
}
}));

View File

@@ -1,9 +1,31 @@
import { GitHub } from '@mui/icons-material';
import { Box, Container, Link, Typography, useTheme } from '@mui/material';
import { Box, Chip, Container, Link, Tooltip, Typography, useTheme } from '@mui/material';
import { useEffect, useState } from 'react';
import { api } from '../utils/apiClient';
const Footer = () => {
const theme = useTheme();
const [updateInfo, setUpdateInfo] = useState<{
hasUpdate: boolean;
latestVersion: string;
releaseUrl: string;
} | null>(null);
useEffect(() => {
const checkVersion = async () => {
try {
const response = await api.get('/system/version');
if (response.data && response.data.hasUpdate) {
setUpdateInfo(response.data);
}
} catch (error) {
// Silently fail for version check
console.debug('Failed to check version:', error);
}
};
checkVersion();
}, []);
return (
<Box
@@ -37,6 +59,28 @@ const Footer = () => {
<Typography variant="caption" color="text.secondary">
v{import.meta.env.VITE_APP_VERSION}
</Typography>
{updateInfo?.hasUpdate && (
<Tooltip title={`New version available: v${updateInfo.latestVersion}`}>
<Link
href={updateInfo.releaseUrl}
target="_blank"
rel="noopener noreferrer"
sx={{ ml: 1, textDecoration: 'none', display: 'flex', alignItems: 'center' }}
>
<Chip
label="Update"
color="success"
size="small"
sx={{
height: 16,
fontSize: '0.65rem',
cursor: 'pointer',
'& .MuiChip-label': { px: 1 }
}}
/>
</Link>
</Tooltip>
)}
</Box>
<Typography variant="caption" color="text.secondary" sx={{ mt: 0.5 }}>
Created by franklioxygen