Display total VOD duration from provider metadata and stream probes
Adapted from MikaelKW/nodecast-tv-plus commit 9c6416cdba40813d44d21c8d76e31e4d9ab3155f. Co-authored-by: Mikael <mikaelkwessel@gmail.com>
This commit is contained in:
@ -1097,6 +1097,7 @@
|
|||||||
<!-- JavaScript -->
|
<!-- JavaScript -->
|
||||||
<script src="/js/icons.js"></script>
|
<script src="/js/icons.js"></script>
|
||||||
<script src="/js/api.js?v=2"></script>
|
<script src="/js/api.js?v=2"></script>
|
||||||
|
<script src="/js/components/VodDuration.js?v=1"></script>
|
||||||
<script src="/js/components/VideoPlayer.js?v=2"></script>
|
<script src="/js/components/VideoPlayer.js?v=2"></script>
|
||||||
<script src="/js/components/ChannelList.js?v=3"></script>
|
<script src="/js/components/ChannelList.js?v=3"></script>
|
||||||
<script src="/js/components/SourceManager.js?v=2"></script>
|
<script src="/js/components/SourceManager.js?v=2"></script>
|
||||||
@ -1107,7 +1108,7 @@
|
|||||||
<script src="/js/pages/MoviesPage.js?v=4"></script>
|
<script src="/js/pages/MoviesPage.js?v=4"></script>
|
||||||
<script src="/js/pages/SeriesPage.js?v=1"></script>
|
<script src="/js/pages/SeriesPage.js?v=1"></script>
|
||||||
<script src="/js/pages/Settings.js?v=2"></script>
|
<script src="/js/pages/Settings.js?v=2"></script>
|
||||||
<script src="/js/pages/WatchPage.js?v=1"></script>
|
<script src="/js/pages/WatchPage.js?v=2"></script>
|
||||||
<script src="/js/app.js?v=4"></script>
|
<script src="/js/app.js?v=4"></script>
|
||||||
<script>
|
<script>
|
||||||
// Check for SSO token in URL
|
// Check for SSO token in URL
|
||||||
|
|||||||
57
public/js/components/VodDuration.js
Normal file
57
public/js/components/VodDuration.js
Normal file
@ -0,0 +1,57 @@
|
|||||||
|
(function (root, factory) {
|
||||||
|
const api = factory();
|
||||||
|
if (typeof module === 'object' && module.exports) module.exports = api;
|
||||||
|
if (root) root.VodDuration = api;
|
||||||
|
}(typeof globalThis !== 'undefined' ? globalThis : this, function () {
|
||||||
|
function parse(value) {
|
||||||
|
if (typeof value === 'number') {
|
||||||
|
return Number.isFinite(value) && value > 0 ? value : 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (typeof value !== 'string') return 0;
|
||||||
|
const normalized = value.trim();
|
||||||
|
if (!normalized) return 0;
|
||||||
|
|
||||||
|
if (/^\d+(?:\.\d+)?$/.test(normalized)) {
|
||||||
|
const seconds = Number(normalized);
|
||||||
|
return Number.isFinite(seconds) && seconds > 0 ? seconds : 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
const parts = normalized.split(':');
|
||||||
|
if (parts.length < 2 || parts.length > 3 || parts.some(part => !/^\d+$/.test(part))) {
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
const numbers = parts.map(Number);
|
||||||
|
if (numbers.slice(parts.length === 3 ? -2 : -1).some(part => part >= 60)) return 0;
|
||||||
|
|
||||||
|
const seconds = parts.length === 3
|
||||||
|
? (numbers[0] * 3600) + (numbers[1] * 60) + numbers[2]
|
||||||
|
: (numbers[0] * 60) + numbers[1];
|
||||||
|
return Number.isFinite(seconds) && seconds > 0 ? seconds : 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
function firstValid(...values) {
|
||||||
|
for (const value of values) {
|
||||||
|
const duration = parse(value);
|
||||||
|
if (duration > 0) return duration;
|
||||||
|
}
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
function fromContent(content = {}) {
|
||||||
|
if (!content || typeof content !== 'object') return 0;
|
||||||
|
return firstValid(
|
||||||
|
content.durationSeconds,
|
||||||
|
content.duration_secs,
|
||||||
|
content.duration,
|
||||||
|
content.runtime_secs,
|
||||||
|
content.info?.duration_secs,
|
||||||
|
content.info?.duration,
|
||||||
|
content.movie_data?.duration_secs,
|
||||||
|
content.movie_data?.duration
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return { parse, firstValid, fromContent };
|
||||||
|
}));
|
||||||
@ -353,7 +353,8 @@ class MoviesPage {
|
|||||||
rating: movie.rating,
|
rating: movie.rating,
|
||||||
sourceId: movie.sourceId,
|
sourceId: movie.sourceId,
|
||||||
categoryId: movie.category_id,
|
categoryId: movie.category_id,
|
||||||
containerExtension: container
|
containerExtension: container,
|
||||||
|
duration: VodDuration.fromContent(movie)
|
||||||
}, result.url);
|
}, result.url);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -430,6 +430,9 @@ class SeriesPage {
|
|||||||
const seasonNum = seasonMatch ? seasonMatch[1] : '1';
|
const seasonNum = seasonMatch ? seasonMatch[1] : '1';
|
||||||
const episodeNum = episodeEl.querySelector('.episode-number')?.textContent?.replace('E', '') || '1';
|
const episodeNum = episodeEl.querySelector('.episode-number')?.textContent?.replace('E', '') || '1';
|
||||||
|
|
||||||
|
const episode = Object.values(this.currentSeriesInfo?.episodes || {}).flat()
|
||||||
|
.find(item => String(item.id) === String(episodeId));
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// Get stream URL for episode (use 'series' type)
|
// Get stream URL for episode (use 'series' type)
|
||||||
const result = await API.proxy.xtream.getStreamUrl(sourceId, episodeId, 'series', container);
|
const result = await API.proxy.xtream.getStreamUrl(sourceId, episodeId, 'series', container);
|
||||||
@ -453,7 +456,8 @@ class SeriesPage {
|
|||||||
seriesInfo: this.currentSeriesInfo,
|
seriesInfo: this.currentSeriesInfo,
|
||||||
currentSeason: seasonNum,
|
currentSeason: seasonNum,
|
||||||
currentEpisode: episodeNum,
|
currentEpisode: episodeNum,
|
||||||
containerExtension: container
|
containerExtension: container,
|
||||||
|
duration: VodDuration.fromContent(episode || {})
|
||||||
}, result.url);
|
}, result.url);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -246,6 +246,7 @@ class WatchPage {
|
|||||||
* @param {string} streamUrl - Stream URL
|
* @param {string} streamUrl - Stream URL
|
||||||
*/
|
*/
|
||||||
async play(content, streamUrl) {
|
async play(content, streamUrl) {
|
||||||
|
this.sourceDuration = 0;
|
||||||
this.content = content;
|
this.content = content;
|
||||||
this.contentType = content.type;
|
this.contentType = content.type;
|
||||||
this.seriesInfo = content.seriesInfo || null;
|
this.seriesInfo = content.seriesInfo || null;
|
||||||
@ -414,7 +415,9 @@ class WatchPage {
|
|||||||
this.currentUrl = url;
|
this.currentUrl = url;
|
||||||
|
|
||||||
// Stop any existing playback
|
// Stop any existing playback
|
||||||
|
const knownDuration = this.sourceDuration;
|
||||||
this.stop();
|
this.stop();
|
||||||
|
this.setSourceDuration(VodDuration.firstValid(VodDuration.fromContent(this.content || {}), knownDuration));
|
||||||
const playbackGeneration = this.playbackGeneration;
|
const playbackGeneration = this.playbackGeneration;
|
||||||
let probeInfo = null;
|
let probeInfo = null;
|
||||||
|
|
||||||
@ -443,6 +446,7 @@ class WatchPage {
|
|||||||
const info = await probeRes.json();
|
const info = await probeRes.json();
|
||||||
if (this.playbackGeneration !== playbackGeneration) return;
|
if (this.playbackGeneration !== playbackGeneration) return;
|
||||||
probeInfo = info;
|
probeInfo = info;
|
||||||
|
this.setSourceDuration(info.duration);
|
||||||
console.log(`[WatchPage] Probe result: video=${info.video}, audio=${info.audio}, ${info.width}x${info.height}, compatible=${info.compatible}`);
|
console.log(`[WatchPage] Probe result: video=${info.video}, audio=${info.audio}, ${info.width}x${info.height}, compatible=${info.compatible}`);
|
||||||
|
|
||||||
// Store early probe info for quality display
|
// Store early probe info for quality display
|
||||||
@ -640,6 +644,13 @@ class WatchPage {
|
|||||||
// Stop history tracking and save final progress
|
// Stop history tracking and save final progress
|
||||||
this.stopHistoryTracking();
|
this.stopHistoryTracking();
|
||||||
this.saveProgress();
|
this.saveProgress();
|
||||||
|
this.sourceDuration = 0;
|
||||||
|
this.durationReady = false;
|
||||||
|
if (this.timeTotal) {
|
||||||
|
this.timeTotal.textContent = '';
|
||||||
|
this.timeTotal.classList.add('hidden');
|
||||||
|
}
|
||||||
|
if (this.durationEl) this.durationEl.textContent = '';
|
||||||
|
|
||||||
// Cleanup transcode session if exists
|
// Cleanup transcode session if exists
|
||||||
this.stopTranscodeSession();
|
this.stopTranscodeSession();
|
||||||
@ -787,8 +798,30 @@ class WatchPage {
|
|||||||
|
|
||||||
// === UI Updates ===
|
// === UI Updates ===
|
||||||
|
|
||||||
|
getPlaybackDuration() {
|
||||||
|
if (this.sourceDuration > 0) return this.sourceDuration;
|
||||||
|
return !this.currentSessionId && this.durationReady ? VodDuration.parse(this.video?.duration) : 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
setSourceDuration(value) {
|
||||||
|
const duration = VodDuration.parse(value);
|
||||||
|
if (duration > 0) this.sourceDuration = duration;
|
||||||
|
this.updateDurationDisplay();
|
||||||
|
}
|
||||||
|
|
||||||
|
updateDurationDisplay() {
|
||||||
|
const duration = this.getPlaybackDuration();
|
||||||
|
const label = duration > 0 ? this.formatTime(duration) : '';
|
||||||
|
if (this.timeTotal) {
|
||||||
|
this.timeTotal.textContent = label;
|
||||||
|
this.timeTotal.classList.toggle('hidden', !label);
|
||||||
|
}
|
||||||
|
if (this.durationEl) this.durationEl.textContent = label;
|
||||||
|
}
|
||||||
|
|
||||||
updateProgress() {
|
updateProgress() {
|
||||||
if (!this.video || !this.video.duration) return;
|
if (!this.video) return;
|
||||||
|
this.updateDurationDisplay();
|
||||||
|
|
||||||
const percent = (this.video.currentTime / this.video.duration) * 100;
|
const percent = (this.video.currentTime / this.video.duration) * 100;
|
||||||
this.progressSlider.value = percent;
|
this.progressSlider.value = percent;
|
||||||
@ -818,6 +851,9 @@ class WatchPage {
|
|||||||
}
|
}
|
||||||
|
|
||||||
onMetadataLoaded() {
|
onMetadataLoaded() {
|
||||||
|
this.durationReady = true;
|
||||||
|
if (!this.currentSessionId) this.setSourceDuration(this.video?.duration);
|
||||||
|
else this.updateDurationDisplay();
|
||||||
// Detect resolution
|
// Detect resolution
|
||||||
if (this.video && this.video.videoHeight > 0) {
|
if (this.video && this.video.videoHeight > 0) {
|
||||||
this.currentStreamInfo = {
|
this.currentStreamInfo = {
|
||||||
@ -886,7 +922,7 @@ class WatchPage {
|
|||||||
}
|
}
|
||||||
|
|
||||||
formatTime(seconds) {
|
formatTime(seconds) {
|
||||||
if (!seconds || isNaN(seconds)) return '0:00';
|
if (!Number.isFinite(Number(seconds)) || Number(seconds) < 0) return '0:00';
|
||||||
const h = Math.floor(seconds / 3600);
|
const h = Math.floor(seconds / 3600);
|
||||||
const m = Math.floor((seconds % 3600) / 60);
|
const m = Math.floor((seconds % 3600) / 60);
|
||||||
const s = Math.floor(seconds % 60);
|
const s = Math.floor(seconds % 60);
|
||||||
@ -1072,6 +1108,7 @@ class WatchPage {
|
|||||||
this.yearEl.textContent = this.content.year || '';
|
this.yearEl.textContent = this.content.year || '';
|
||||||
this.ratingEl.textContent = this.content.rating ? `★ ${this.content.rating}` : '';
|
this.ratingEl.textContent = this.content.rating ? `★ ${this.content.rating}` : '';
|
||||||
this.descriptionEl.textContent = this.content.description || '';
|
this.descriptionEl.textContent = this.content.description || '';
|
||||||
|
this.updateDurationDisplay();
|
||||||
|
|
||||||
// Update play button text
|
// Update play button text
|
||||||
if (this.playBtnText) {
|
if (this.playBtnText) {
|
||||||
@ -1194,7 +1231,8 @@ class WatchPage {
|
|||||||
year: movie.year,
|
year: movie.year,
|
||||||
rating: movie.rating,
|
rating: movie.rating,
|
||||||
sourceId: sourceId,
|
sourceId: sourceId,
|
||||||
categoryId: movie.category_id
|
categoryId: movie.category_id,
|
||||||
|
duration: VodDuration.fromContent(movie)
|
||||||
}, result.url);
|
}, result.url);
|
||||||
}
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
@ -1260,6 +1298,7 @@ class WatchPage {
|
|||||||
const episodeId = episodeEl.dataset.episodeId;
|
const episodeId = episodeEl.dataset.episodeId;
|
||||||
const seasonNum = episodeEl.dataset.season;
|
const seasonNum = episodeEl.dataset.season;
|
||||||
const episodeNum = episodeEl.dataset.episode;
|
const episodeNum = episodeEl.dataset.episode;
|
||||||
|
const episode = this.seriesInfo?.episodes?.[seasonNum]?.find(ep => String(ep.id) === String(episodeId));
|
||||||
const container = episodeEl.dataset.container || 'mp4';
|
const container = episodeEl.dataset.container || 'mp4';
|
||||||
|
|
||||||
try {
|
try {
|
||||||
@ -1281,7 +1320,8 @@ class WatchPage {
|
|||||||
seriesId: this.content.seriesId,
|
seriesId: this.content.seriesId,
|
||||||
seriesInfo: this.seriesInfo,
|
seriesInfo: this.seriesInfo,
|
||||||
currentSeason: seasonNum,
|
currentSeason: seasonNum,
|
||||||
currentEpisode: episodeNum
|
currentEpisode: episodeNum,
|
||||||
|
duration: VodDuration.fromContent(episode || {})
|
||||||
}, result.url);
|
}, result.url);
|
||||||
}
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
@ -1372,7 +1412,8 @@ class WatchPage {
|
|||||||
seriesId: this.content.seriesId,
|
seriesId: this.content.seriesId,
|
||||||
seriesInfo: this.seriesInfo,
|
seriesInfo: this.seriesInfo,
|
||||||
currentSeason: nextEp.seasonNum,
|
currentSeason: nextEp.seasonNum,
|
||||||
currentEpisode: nextEp.episode_num
|
currentEpisode: nextEp.episode_num,
|
||||||
|
duration: VodDuration.fromContent(nextEp)
|
||||||
}, result.url);
|
}, result.url);
|
||||||
}
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
|
|||||||
@ -133,6 +133,8 @@ function analyzeProbeResult(probeResult, url) {
|
|||||||
return {
|
return {
|
||||||
video: videoCodec,
|
video: videoCodec,
|
||||||
audio: audioCodec,
|
audio: audioCodec,
|
||||||
|
duration: Number.isFinite(Number(format.duration)) && Number(format.duration) > 0
|
||||||
|
? Number(format.duration) : 0,
|
||||||
audioCodecs: streams.filter(s => s.codec_type === 'audio')
|
audioCodecs: streams.filter(s => s.codec_type === 'audio')
|
||||||
.map(s => s.codec_name?.toLowerCase() || 'unknown'),
|
.map(s => s.codec_name?.toLowerCase() || 'unknown'),
|
||||||
width: videoStream?.width || 0,
|
width: videoStream?.width || 0,
|
||||||
|
|||||||
@ -21,7 +21,7 @@ test('probe preserves every audio track index including unknown codecs', () => {
|
|||||||
|
|
||||||
function playerFixture(name, settings, probe) {
|
function playerFixture(name, settings, probe) {
|
||||||
const window = { dispatchEvent() {} };
|
const window = { dispatchEvent() {} };
|
||||||
const context = { window, console: { log() {}, warn() {}, error() {} },
|
const context = { window, VodDuration: require('../public/js/components/VodDuration'), console: { log() {}, warn() {}, error() {} },
|
||||||
CustomEvent: class {}, document: { getElementById() { return null; } },
|
CustomEvent: class {}, document: { getElementById() { return null; } },
|
||||||
API: { settings: { get: async () => settings } }, fetch: probe };
|
API: { settings: { get: async () => settings } }, fetch: probe };
|
||||||
const file = name === 'VideoPlayer' ? 'components/VideoPlayer.js' : 'pages/WatchPage.js';
|
const file = name === 'VideoPlayer' ? 'components/VideoPlayer.js' : 'pages/WatchPage.js';
|
||||||
|
|||||||
50
test/vod-duration.test.js
Normal file
50
test/vod-duration.test.js
Normal file
@ -0,0 +1,50 @@
|
|||||||
|
const test = require('node:test');
|
||||||
|
const assert = require('node:assert/strict');
|
||||||
|
const fs = require('node:fs');
|
||||||
|
const path = require('node:path');
|
||||||
|
const vm = require('node:vm');
|
||||||
|
const VodDuration = require('../public/js/components/VodDuration');
|
||||||
|
|
||||||
|
test('VOD duration understands provider fields, clock strings and invalid values', () => {
|
||||||
|
for (const [input, expected] of [[5400, 5400], ['5400.5', 5400.5], ['01:30:00', 5400], ['45:30', 2730], ['90:00', 5400]]) {
|
||||||
|
assert.equal(VodDuration.parse(input), expected);
|
||||||
|
}
|
||||||
|
for (const value of [Infinity, NaN, -1, null, {}, '', '1:99:00', '1:2:3:4', 'unknown']) assert.equal(VodDuration.parse(value), 0);
|
||||||
|
assert.equal(VodDuration.fromContent({ info: { duration: '01:30:00' } }), 5400);
|
||||||
|
assert.equal(VodDuration.fromContent({ duration_secs: 0, duration: '45:30' }), 2730);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('Watch displays provider totals for sessions, browser totals for direct media, and resets between content', () => {
|
||||||
|
const window = {};
|
||||||
|
vm.runInNewContext(fs.readFileSync(path.join(__dirname, '../public/js/pages/WatchPage.js'), 'utf8'), { window, VodDuration });
|
||||||
|
const watch = Object.create(window.WatchPage.prototype);
|
||||||
|
let hidden;
|
||||||
|
Object.assign(watch, { video: { duration: Infinity }, durationEl: {},
|
||||||
|
timeTotal: { classList: { toggle(key, value) { hidden = value; } } } });
|
||||||
|
watch.setSourceDuration('01:30:00');
|
||||||
|
assert.equal(watch.timeTotal.textContent, '1:30:00');
|
||||||
|
assert.equal(watch.durationEl.textContent, '1:30:00');
|
||||||
|
assert.equal(hidden, false);
|
||||||
|
watch.currentSessionId = 'session';
|
||||||
|
watch.video.duration = 24;
|
||||||
|
watch.updateDurationDisplay();
|
||||||
|
assert.equal(watch.timeTotal.textContent, '1:30:00');
|
||||||
|
watch.currentSessionId = null;
|
||||||
|
watch.video.duration = 5405;
|
||||||
|
watch.setSourceDuration(watch.video.duration);
|
||||||
|
assert.equal(watch.timeTotal.textContent, '1:30:05');
|
||||||
|
watch.sourceDuration = 0; watch.video.duration = NaN;
|
||||||
|
watch.updateDurationDisplay();
|
||||||
|
assert.equal(watch.durationEl.textContent, '');
|
||||||
|
assert.equal(hidden, true);
|
||||||
|
watch.sourceDuration = 5400; watch.currentSessionId = 'old'; watch.video.duration = 24;
|
||||||
|
let savedDuration;
|
||||||
|
Object.assign(watch, { stopHistoryTracking() {}, saveProgress() { savedDuration = this.getPlaybackDuration(); },
|
||||||
|
stopTranscodeSession() { this.currentSessionId = null; }, updateTranscodeStatus() {}, hideNowPlaying() {} });
|
||||||
|
watch.timeTotal.classList.add = () => { hidden = true; };
|
||||||
|
watch.video.pause = () => {}; watch.video.load = () => {};
|
||||||
|
watch.stop();
|
||||||
|
assert.equal(savedDuration, 5400);
|
||||||
|
assert.equal(watch.timeTotal.textContent, '');
|
||||||
|
assert.equal(watch.getPlaybackDuration(), 0);
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user