peertube-plugin-youth-schedule
Version:
PeerTube plugin: youth protection schedule (configurable HH:mm slots and end time)
300 lines (264 loc) • 11 kB
JavaScript
async function register({ registerHook, storageManager, getRouter, registerSetting, settingsManager }) {
// Настройки плагина: источник времени (user/server)
if (registerSetting) {
registerSetting({
name: 'timeSource',
label: 'Zeitquelle',
type: 'select',
default: 'server',
descriptionHTML: 'Welche Zeit wird verwendet, um Verfügbarkeit zu prüfen?',
options: [
{ value: 'server', label: 'Serverzeit' },
{ value: 'user', label: 'Browserzeit (Benutzer)' }
]
})
// Редактируемые временные слоты, CSV часов/минут, например: 20,22:30,23:15
registerSetting({
name: 'timeSlots',
label: 'Sendezeit-Slots (CSV HH:mm oder HH)',
type: 'input',
default: '20,22,23',
descriptionHTML: 'Zulässige Startzeiten als CSV, z.B. 20,22:30,23:15. Gültig 00:00–23:59.'
})
// Конец окна доступности
registerSetting({
name: 'endTime',
label: 'Endzeit (HH:mm)',
type: 'input',
default: '06:00',
descriptionHTML: 'Zeitpunkt, bis wann Inhalte sichtbar sind. Format HH:mm. Kann kleiner als Startzeit sein (über Mitternacht).'
})
}
const router = getRouter()
const allowedCache = new Map()
const ALLOWED_CACHE_TTL = 30 * 1000 // 30 seconds
// Роут: получить конфиг плагина для клиента
router.get('/config', async (_req, res) => {
try {
const timeSource = (await getSetting(settingsManager, 'timeSource')) || 'server'
const slotsCsv = (await getSetting(settingsManager, 'timeSlots')) || '20,22,23'
const endTimeStr = normalizeTimeString((await getSetting(settingsManager, 'endTime')) || '06:00')
const slotMinutes = parseSlotsToMinutes(slotsCsv)
const slotLabels = slotMinutes.map(m => minutesToLabel(m))
res.json({ timeSource, slots: slotLabels, endTime: endTimeStr })
} catch (err) {
res.status(500).json({ error: err.message })
}
})
// Роут: проверка доступности видео
router.get('/video/:uuid/youth-allowed', async (req, res) => {
try {
const videoUuid = req.params.uuid
const timeSource = (await getSetting(settingsManager, 'timeSource')) || 'server'
const endTimeStr = normalizeTimeString((await getSetting(settingsManager, 'endTime')) || '06:00')
const endMinutes = timeStringToMinutes(endTimeStr)
// Текущее время в минутах суток
const now = new Date()
let currentMinutes
if (timeSource === 'server') {
currentMinutes = now.getHours() * 60 + now.getMinutes()
} else {
const cm = Number(req.query.clientMinutes)
const ch = Number(req.query.clientHour)
if (Number.isFinite(cm)) currentMinutes = cm
else if (Number.isFinite(ch)) currentMinutes = ch * 60
else currentMinutes = now.getHours() * 60 + now.getMinutes()
}
const cacheKey = `${videoUuid}_${currentMinutes}`
const cached = allowedCache.get(cacheKey)
if (cached && Date.now() - cached.time < ALLOWED_CACHE_TTL) {
return res.json(cached.data)
}
const meta = await storageManager.getData('youth-schedule-' + videoUuid)
// Блокируем только если в метаданных включён чекбокс
if (!meta || !meta.youthSensitive) {
const result = { allowed: true, debug: { reason: 'not-sensitive', youthSensitive: !!(meta && meta.youthSensitive) } }
if (allowedCache.size > 5000) allowedCache.clear()
allowedCache.set(cacheKey, { data: result, time: Date.now() })
return res.json(result)
}
// Время старта из меты (минуты). Поддерживаем старый формат часов
let openMinutes = null
if (meta.youthOpenMinutes != null && meta.youthOpenMinutes !== '') {
const parsed = Number(meta.youthOpenMinutes)
if (Number.isFinite(parsed)) openMinutes = clampMinutes(parsed)
} else if (meta.youthOpenHour != null && meta.youthOpenHour !== '') {
const h = parseInt(meta.youthOpenHour, 10)
if (Number.isFinite(h)) openMinutes = clampMinutes(h * 60)
}
// Если время не установлено, видео доступно (youthSensitive=true, но без ограничений по времени)
if (openMinutes === null) {
const result = { allowed: true, debug: { reason: 'no-openMinutes', youthSensitive: true } }
if (allowedCache.size > 5000) allowedCache.clear()
allowedCache.set(cacheKey, { data: result, time: Date.now() })
return res.json(result)
}
const allowed = isWithinAllowedWindowMinutes(currentMinutes, openMinutes, endMinutes)
const result = {
allowed,
openLabel: minutesToLabel(openMinutes),
endLabel: endTimeStr,
debug: { timeSource, currentMinutes, openMinutes, endMinutes }
}
if (allowedCache.size > 5000) allowedCache.clear()
allowedCache.set(cacheKey, { data: result, time: Date.now() })
return res.json(result)
} catch (err) {
res.status(500).json({ error: err.message })
}
})
// Сохранение и восстановление метаданных при апдейте видео
registerHook({
target: 'action:api.video.updated',
handler: ({ video, req }) => {
if (!req.body || !req.body.pluginData) return
const pluginData = req.body.pluginData
const youthSensitive = !!pluginData.youthSensitive
const youthOpenMinutes = normalizeOpenMinutes(pluginData)
// Если ограничения сняты, НЕ удаляем данные (PeerTube не имеет deleteData API)
// Данные остаются в БД, но при проверке youthSensitive=false игнорируются
if (!youthSensitive && (youthOpenMinutes === null || youthOpenMinutes === 0)) {
return
}
// Сохраняем только если есть хотя бы одно ограничение
// youthSensitive = true или youthOpenMinutes установлено (включая 0 = полночь)
if (youthSensitive || youthOpenMinutes !== null) {
const json = {
youthSensitive,
youthOpenMinutes: youthOpenMinutes !== null ? youthOpenMinutes : null
}
storageManager.storeData('youth-schedule-' + video.uuid, json)
}
}
})
// При создании тоже сохраняем
registerHook({
target: 'action:api.video.uploaded',
handler: ({ video, req }) => {
if (!req.body || !req.body.pluginData) return
const pluginData = req.body.pluginData
const youthSensitive = !!pluginData.youthSensitive
const youthOpenMinutes = normalizeOpenMinutes(pluginData)
// Если ограничения сняты, не сохраняем данные
if (!youthSensitive && (youthOpenMinutes === null || youthOpenMinutes === 0)) {
return
}
// Сохраняем только если есть хотя бы одно ограничение
// youthSensitive = true или youthOpenMinutes установлено (включая 0 = полночь)
if (youthSensitive || youthOpenMinutes !== null) {
const json = {
youthSensitive,
youthOpenMinutes: youthOpenMinutes !== null ? youthOpenMinutes : null
}
storageManager.storeData('youth-schedule-' + video.uuid, json)
}
}
})
// Отдаём данные вместе с видео
registerHook({
target: 'filter:api.video.get.result',
handler: async (video) => {
if (!video) return video
if (!video.pluginData) video.pluginData = {}
const result = await storageManager.getData('youth-schedule-' + video.uuid)
if (result) {
Object.assign(video.pluginData, result)
// Совместимость: для старых форм с часами
if (!video.pluginData.youthOpenMinutes && video.pluginData.youthOpenHour) {
const h = parseInt(video.pluginData.youthOpenHour, 10)
if (Number.isFinite(h)) video.pluginData.youthOpenMinutes = h * 60
}
}
return video
}
})
}
function isWithinAllowedWindowMinutes(currentMinutes, openMinutes, endMinutes) {
// Если окно не пересекает полночь: [open..end)
if (openMinutes < endMinutes) {
return currentMinutes >= openMinutes && currentMinutes < endMinutes
}
// Окно через полночь: [open..24h) U [0..end)
if (openMinutes > endMinutes) {
return currentMinutes >= openMinutes || currentMinutes < endMinutes
}
// Равенство openMinutes === endMinutes: трактуем как 24 часа доступности
// (видео доступно всегда, так как окно не определено)
return true
}
function parseSlotsToMinutes(csv) {
return String(csv)
.split(',')
.map(s => s.trim())
.filter(Boolean)
.map(timeStringToMinutes)
.filter(n => Number.isFinite(n))
.map(clampMinutes)
}
function clampMinutes(v) {
if (!Number.isFinite(v)) return 0
if (v < 0) return 0
if (v >= 24 * 60) return 24 * 60 - 1
return v
}
function minutesToLabel(minutes) {
const h = Math.floor(minutes / 60)
const m = minutes % 60
return `${String(h).padStart(2, '0')}:${String(m).padStart(2, '0')}`
}
function normalizeTimeString(str) {
const m = timeStringToMinutes(str)
return minutesToLabel(m)
}
function timeStringToMinutes(str) {
// Поддержка 'H', 'HH', 'H:mm', 'HH:mm'
const s = String(str).trim()
if (!s) return 0
if (/^\d{1,2}$/.test(s)) {
const h = Math.min(23, Math.max(0, parseInt(s, 10)))
return h * 60
}
const m = s.match(/^(\d{1,2}):(\d{1,2})$/)
if (m) {
const h = Math.min(23, Math.max(0, parseInt(m[1], 10)))
const mm = Math.min(59, Math.max(0, parseInt(m[2], 10)))
return h * 60 + mm
}
const n = Number(s)
if (Number.isFinite(n)) return clampMinutes(n)
return 0
}
function normalizeOpenMinutes(pluginData) {
if (!pluginData) return null
// Новый формат: минутный
if (pluginData.youthOpenMinutes != null && pluginData.youthOpenMinutes !== '') {
const v = Number(pluginData.youthOpenMinutes)
if (Number.isFinite(v)) return clampMinutes(v)
}
// Старый формат: час
if (pluginData.youthOpenHour != null && pluginData.youthOpenHour !== '') {
const h = parseInt(pluginData.youthOpenHour, 10)
if (Number.isFinite(h)) return clampMinutes(h * 60)
}
// Возвращаем null, если значение не установлено (отличается от 0 = полночь)
return null
}
const settingsCache = new Map()
const SETTINGS_CACHE_TTL = 60 * 1000
async function getSetting(settingsManager, name) {
if (!settingsManager || !settingsManager.getSetting) return null
const now = Date.now()
const cached = settingsCache.get(name)
if (cached && now - cached.time < SETTINGS_CACHE_TTL) {
return cached.value
}
try {
const value = await settingsManager.getSetting(name)
settingsCache.set(name, { value, time: now })
return value
} catch (_) {
return null
}
}
async function unregister() { return }
module.exports = { register, unregister }