Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
45 changes: 45 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
# Dependencies
node_modules/

# Environment variables
.env
.env.local
.env.*.local

# Logs
logs/
*.log
npm-debug.log*

# Runtime data
pids/
*.pid
*.seed
*.pid.lock

# Build outputs
dist/
build/

# OS files
.DS_Store
Thumbs.db

# IDE
.idea/
.vscode/
*.swp
*.swo

# Database
*.db
*.sqlite
*.sqlite3

# Temporary files
tmp/
temp/

# App data directories (local dev)
data/
dvr/
2 changes: 2 additions & 0 deletions Dockerfile
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,8 @@ ARG TARGETARCH
ENV NVIDIA_DRIVER_CAPABILITIES=all
ENV DEBIAN_FRONTEND=noninteractive
ENV LD_LIBRARY_PATH=/usr/lib/jellyfin-ffmpeg/lib
ENV DATA_DIR=/data
ENV DVR_DIR=/dvr

# Install only the necessary runtime dependencies: Node.js, FFmpeg, and drivers.
# We also add 'ca-certificates' which is crucial for making HTTPS requests from Node.js.
Expand Down
13 changes: 11 additions & 2 deletions public/js/modules/admin.js
Original file line number Diff line number Diff line change
Expand Up @@ -179,12 +179,17 @@ function renderLiveActivity() {
? `<span class="transcode-indicator bg-orange-400" title="Transcoding with FFmpeg"></span>`
: `<span class="transcode-indicator bg-green-400" title="Direct Redirect"></span>`;

// Use image proxy for channel logos
const channelLogo = stream.channelLogo && stream.channelLogo.startsWith('http')
? `/api/image-proxy?url=${encodeURIComponent(stream.channelLogo)}`
: stream.channelLogo || 'https://placehold.co/40x40/1f2937/d1d5db?text=?';

tr.innerHTML = `
<td><span class="clickable-username" title="Filter history for this user">${stream.username}</span></td>
<td>${stream.clientIp || 'N/A'}</td>
<td>
<div class="flex items-center gap-3">
<img src="${stream.channelLogo || 'https://placehold.co/40x40/1f2937/d1d5db?text=?'}"
<img src="${channelLogo}"
onerror="this.onerror=null; this.src='https://placehold.co/40x40/1f2937/d1d5db?text=?';"
class="w-10 h-10 object-contain rounded-md bg-gray-700 flex-shrink-0"
alt="Channel Logo">
Expand Down Expand Up @@ -232,12 +237,16 @@ function renderWatchHistory() {

history.forEach(entry => {
const tr = document.createElement('tr');
// Use image proxy for channel logos
const channelLogo = entry.channel_logo && entry.channel_logo.startsWith('http')
? `/api/image-proxy?url=${encodeURIComponent(entry.channel_logo)}`
: entry.channel_logo || 'https://placehold.co/40x40/1f2937/d1d5db?text=?';
tr.innerHTML = `
<td><span class="clickable-username" title="Filter history for this user">${entry.username}</span></td>
<td>${entry.client_ip || 'N/A'}</td>
<td>
<div class="flex items-center gap-3">
<img src="${entry.channel_logo || 'https://placehold.co/40x40/1f2937/d1d5db?text=?'}"
<img src="${channelLogo}"
onerror="this.onerror=null; this.src='https://placehold.co/40x40/1f2937/d1d5db?text=?';"
class="w-10 h-10 object-contain rounded-md bg-gray-700 flex-shrink-0"
alt="Channel Logo">
Expand Down
54 changes: 35 additions & 19 deletions public/js/modules/guide.js
Original file line number Diff line number Diff line change
Expand Up @@ -56,7 +56,10 @@ export function openProgramDetails(progItem) {
}

const channelName = channelData.displayName || channelData.name;
const channelLogo = channelData.logo;
// Use image proxy for channel logos
const channelLogo = channelData.logo && channelData.logo.startsWith('http')
? `/api/image-proxy?url=${encodeURIComponent(channelData.logo)}`
: channelData.logo;
const channelUrl = channelData.url;

// Get fresh references to buttons inside the modal
Expand Down Expand Up @@ -513,10 +516,13 @@ const renderGuide = (channelsToRender, resetScroll = false, shouldCenter = false
`data-channel-id="${channel.id}" class="w-6 h-6 text-gray-500 hover:text-yellow-400 favorite-star cursor-pointer ${channel.isFavorite ? 'favorited' : ''}"`
);

const proxiedLogo = channel.logo && channel.logo.startsWith('http')
? `/api/image-proxy?url=${encodeURIComponent(channel.logo)}`
: channel.logo;
const channelInfoHTML = `
<div class="channel-info p-2 flex items-center justify-between cursor-pointer" data-url="${channel.url}" data-name="${channelName}" data-id="${channel.id}" data-channel-index="${i}">
<div class="flex items-center overflow-hidden flex-grow min-w-0">
<img src="${channel.logo}" onerror="this.onerror=null; this.src='https://placehold.co/48x48/1f2937/d1d5db?text=?';" class="w-12 h-12 object-contain mr-3 flex-shrink-0 rounded-md bg-gray-700">
<img src="${proxiedLogo}" onerror="this.onerror=null; this.src='https://placehold.co/48x48/1f2937/d1d5db?text=?';" class="w-12 h-12 object-contain mr-3 flex-shrink-0 rounded-md bg-gray-700">
<div class="flex-grow min-w-0 channel-details">
<span class="font-semibold text-sm truncate block">${channelName}</span>
<div class="flex items-center gap-2 mt-1">
Expand Down Expand Up @@ -840,30 +846,40 @@ const renderSearchResults = (channelResults, programResults) => {

if (channelResults.length > 0) {
html += '<div class="search-results-header">Channels</div>';
html += channelResults.map(({ item }) => `
<div class="search-result-channel flex items-center p-3 border-b border-gray-700/50 hover:bg-gray-700 cursor-pointer" data-channel-id="${item.id}">
<img src="${item.logo}" onerror="this.onerror=null; this.src='https://placehold.co/40x40/1f2937/d1d5db?text=?';" class="w-10 h-10 object-contain mr-3 rounded-md bg-gray-700 flex-shrink-0">
<div class="overflow-hidden">
<p class="font-semibold text-white text-sm truncate">${item.chno ? `[${item.chno}] ` : ''}${item.displayName || item.name}</p>
<p class="text-gray-400 text-xs truncate">${item.group} &bull; ${item.source}</p>
html += channelResults.map(({ item }) => {
const proxiedLogo = item.logo && item.logo.startsWith('http')
? `/api/image-proxy?url=${encodeURIComponent(item.logo)}`
: item.logo;
return `
<div class="search-result-channel flex items-center p-3 border-b border-gray-700/50 hover:bg-gray-700 cursor-pointer" data-channel-id="${item.id}">
<img src="${proxiedLogo}" onerror="this.onerror=null; this.src='https://placehold.co/40x40/1f2937/d1d5db?text=?';" class="w-10 h-10 object-contain mr-3 rounded-md bg-gray-700 flex-shrink-0">
<div class="overflow-hidden">
<p class="font-semibold text-white text-sm truncate">${item.chno ? `[${item.chno}] ` : ''}${item.displayName || item.name}</p>
<p class="text-gray-400 text-xs truncate">${item.group} &bull; ${item.source}</p>
</div>
</div>
</div>
`).join('');
`;
}).join('');
}

if (programResults.length > 0) {
html += '<div class="search-results-header">Programs</div>';
const timeFormat = { hour: '2-digit', minute: '2-digit' };
html += programResults.map(({ item }) => `
<div class="search-result-program flex items-center p-3 border-b border-gray-700/50 hover:bg-gray-700 cursor-pointer" data-channel-id="${item.channel.id}" data-prog-start="${item.start}">
<img src="${item.channel.logo}" onerror="this.onerror=null; this.src='https://placehold.co/40x40/1f2937/d1d5db?text=?';" class="w-10 h-10 object-contain mr-3 rounded-md bg-gray-700 flex-shrink-0">
<div class="overflow-hidden">
<p class="font-semibold text-white text-sm truncate" title="${item.title}">${item.title}</p>
<p class="text-gray-400 text-xs truncate">${item.channel.name} &bull; ${item.channel.source}</p>
<p class="text-blue-400 text-xs">${new Date(item.start).toLocaleTimeString([], timeFormat)} - ${new Date(item.stop).toLocaleTimeString([], timeFormat)}</p>
html += programResults.map(({ item }) => {
const proxiedLogo = item.channel.logo && item.channel.logo.startsWith('http')
? `/api/image-proxy?url=${encodeURIComponent(item.channel.logo)}`
: item.channel.logo;
return `
<div class="search-result-program flex items-center p-3 border-b border-gray-700/50 hover:bg-gray-700 cursor-pointer" data-channel-id="${item.channel.id}" data-prog-start="${item.start}">
<img src="${proxiedLogo}" onerror="this.onerror=null; this.src='https://placehold.co/40x40/1f2937/d1d5db?text=?';" class="w-10 h-10 object-contain mr-3 rounded-md bg-gray-700 flex-shrink-0">
<div class="overflow-hidden">
<p class="font-semibold text-white text-sm truncate" title="${item.title}">${item.title}</p>
<p class="text-gray-400 text-xs truncate">${item.channel.name} &bull; ${item.channel.source}</p>
<p class="text-blue-400 text-xs">${new Date(item.start).toLocaleTimeString([], timeFormat)} - ${new Date(item.stop).toLocaleTimeString([], timeFormat)}</p>
</div>
</div>
</div>
`).join('');
`;
}).join('');
}

if (html) {
Expand Down
116 changes: 75 additions & 41 deletions server.js
Original file line number Diff line number Diff line change
Expand Up @@ -60,8 +60,8 @@ const activeStreamProcesses = new Map();
const STREAM_INACTIVITY_TIMEOUT = 30000; // 30 seconds to kill an inactive stream process

// --- Configuration ---
const DATA_DIR = '/data';
const DVR_DIR = '/dvr';
const DATA_DIR = process.env.DATA_DIR || path.join(__dirname, 'data');
const DVR_DIR = process.env.DVR_DIR || path.join(__dirname, 'dvr');
const LOGS_DIR = path.join(DATA_DIR, 'logs'); // NEW: Log management directory
const VAPID_KEYS_PATH = path.join(DATA_DIR, 'vapid.json');
const SOURCES_DIR = path.join(DATA_DIR, 'sources');
Expand Down Expand Up @@ -4843,53 +4843,87 @@ app.get('/api/image-proxy', allowLocalOrAuth, (req, res) => {

console.log(`[IMAGE_PROXY] Fetching and caching image: ${imageUrl}`);

// Determine protocol (http or https)
const protocol = imageUrl.startsWith('https') ? https : http;

protocol.get(imageUrl, (imageRes) => {
// Check if response is an image
const contentType = imageRes.headers['content-type'];
if (!contentType || !contentType.startsWith('image/')) {
console.error(`[IMAGE_PROXY] Invalid content type: ${contentType}`);
return res.status(400).send('URL does not point to an image');
// Helper function to fetch image with redirect support
const fetchImage = (url, redirectCount = 0) => {
if (redirectCount > 5) {
console.error(`[IMAGE_PROXY] Too many redirects for: ${imageUrl}`);
return res.status(400).send('Too many redirects');
}

// Set response headers
res.setHeader('Content-Type', contentType);
res.setHeader('Cache-Control', 'public, max-age=2592000'); // 30 days
res.setHeader('X-Cache', 'MISS');

// Create write stream to save to cache
const fileStream = fs.createWriteStream(cacheFilePath);
// Determine protocol (http or https)
const protocol = url.startsWith('https') ? https : http;

// Parse URL for proper request options
const parsedUrl = new URL(url);
const requestOptions = {
hostname: parsedUrl.hostname,
port: parsedUrl.port || (protocol === https ? 443 : 80),
path: parsedUrl.pathname + parsedUrl.search,
headers: {
'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36',
'Accept': 'image/avif,image/webp,image/apng,image/svg+xml,image/*,*/*;q=0.8',
'Accept-Language': 'en-US,en;q=0.9',
'Referer': parsedUrl.origin + '/'
}
};

// Pipe to both cache and response
imageRes.pipe(fileStream);
imageRes.pipe(res);
protocol.get(requestOptions, (imageRes) => {
// Handle redirects (301, 302, 303, 307, 308)
if ([301, 302, 303, 307, 308].includes(imageRes.statusCode) && imageRes.headers.location) {
const redirectUrl = imageRes.headers.location.startsWith('http')
? imageRes.headers.location
: new URL(imageRes.headers.location, url).href;
console.log(`[IMAGE_PROXY] Following redirect to: ${redirectUrl}`);
return fetchImage(redirectUrl, redirectCount + 1);
}

// Save metadata when done
fileStream.on('finish', () => {
const meta = {
url: imageUrl,
contentType: contentType,
cachedAt: new Date().toISOString()
};
try {
fs.writeFileSync(cacheMetaPath, JSON.stringify(meta, null, 2));
console.log(`[IMAGE_PROXY] Cached image: ${urlHash}`);
} catch (err) {
console.error(`[IMAGE_PROXY] Failed to write cache metadata:`, err.message);
// Check if response is an image
const contentType = imageRes.headers['content-type'];
if (!contentType || !contentType.startsWith('image/')) {
console.error(`[IMAGE_PROXY] Invalid content type: ${contentType} (status: ${imageRes.statusCode})`);
return res.status(400).send('URL does not point to an image');
}
});

fileStream.on('error', (err) => {
console.error(`[IMAGE_PROXY] Error writing to cache:`, err.message);
// Continue serving even if cache write fails
// Set response headers
res.setHeader('Content-Type', contentType);
res.setHeader('Cache-Control', 'public, max-age=2592000'); // 30 days
res.setHeader('X-Cache', 'MISS');

// Create write stream to save to cache
const fileStream = fs.createWriteStream(cacheFilePath);

// Pipe to both cache and response
imageRes.pipe(fileStream);
imageRes.pipe(res);

// Save metadata when done
fileStream.on('finish', () => {
const meta = {
url: imageUrl,
contentType: contentType,
cachedAt: new Date().toISOString()
};
try {
fs.writeFileSync(cacheMetaPath, JSON.stringify(meta, null, 2));
console.log(`[IMAGE_PROXY] Cached image: ${urlHash}`);
} catch (err) {
console.error(`[IMAGE_PROXY] Failed to write cache metadata:`, err.message);
}
});

fileStream.on('error', (err) => {
console.error(`[IMAGE_PROXY] Error writing to cache:`, err.message);
// Continue serving even if cache write fails
});

}).on('error', (err) => {
console.error(`[IMAGE_PROXY] Error fetching image from ${url}:`, err.message);
res.status(500).send('Failed to fetch image');
});
};

}).on('error', (err) => {
console.error(`[IMAGE_PROXY] Error fetching image from ${imageUrl}:`, err.message);
res.status(500).send('Failed to fetch image');
});
// Start fetching
fetchImage(imageUrl);
});


Expand Down