Before making any changes, it's important to understand that KVS already includes solid SEO foundations out of the box:
video:video tags including thumbnail, content URL, duration, rating, tags)The improvements below add what's missing — additional structured data, faster indexing, and better crawl budget management.
KVS includes a basic VideoObject schema. You can enhance it by adding more fields that Google uses for rich results. Edit your include_header_general.tpl and find the existing VideoObject JSON-LD block. Replace it with this enhanced version:
{{if $storage.video_view_video_view.video_id>0}}
{{assign var="duration_hours" value=$storage.video_view_video_view.duration_minutes/60|intval}}
{{assign var="duration_minutes" value=$storage.video_view_video_view.duration_minutes-$duration_hours*60}}
{{assign var="duration_seconds" value=$storage.video_view_video_view.duration_seconds}}
<script type="application/ld+json">
{
"@context": "https://schema.org",
"@type": "VideoObject",
"name": "{{$storage.video_view_video_view.title|json_encode|trim:'"'}}",
"description": "{{$storage.video_view_video_view.description|default:$storage.video_view_video_view.title|json_encode|trim:'"'}}",
"thumbnailUrl": [
"{{$storage.video_view_video_view.preview_url}}",
"{{$storage.video_view_video_view.screen_url}}/preview.jpg"
],
"uploadDate": "{{$storage.video_view_video_view.post_date|replace:" ":"T"}}Z",
"duration": "PT{{$duration_hours}}H{{$duration_minutes}}M{{$duration_seconds}}S",
{{assign var="video_file_url" value=""}}
{{if $storage.video_view_video_view.load_type_id==1}}
{{foreach from=$lang.videos.sitemap_formats item="sitemap_postfix"}}
{{if $storage.video_view_video_view.formats[$sitemap_postfix].file_url}}
{{assign var="video_file_url" value=$storage.video_view_video_view.formats[$sitemap_postfix].file_url}}
{{/if}}
{{/foreach}}
{{elseif $storage.video_view_video_view.load_type_id==2}}
{{assign var="video_file_url" value=$storage.video_view_video_view.file_url}}
{{/if}}
{{if $video_file_url}}
"contentUrl": "{{$video_file_url}}",
{{else}}
"embedUrl": "{{$config.project_url}}/embed/{{$storage.video_view_video_view.video_id}}",
{{/if}}
"interactionStatistic": [
{
"@type": "InteractionCounter",
"interactionType": {"@type": "WatchAction"},
"userInteractionCount": "{{$storage.video_view_video_view.video_viewed}}"
},
{
"@type": "InteractionCounter",
"interactionType": {"@type": "LikeAction"},
"userInteractionCount": "{{$storage.video_view_video_view.rating_amount}}"
}
],
{{if count($storage.video_view_video_view.categories)>0}}
"genre": "{{$storage.video_view_video_view.categories.0.title|json_encode|trim:'"'}}",
{{/if}}
"isFamilyFriendly": false
}
</script>
{{/if}}
What's improved:
thumbnailUrl as an array — provides multiple thumbnail options for GoogleinteractionType as proper objects ({"@type": "WatchAction"}) instead of plain strings — follows current Schema.org specgenre field from the first categoryisFamilyFriendly flagBreadcrumbList tells Google the page hierarchy. Google displays these as clickable breadcrumbs in search results, which improves click-through rate.
Add this to your include_header_general.tpl, right after the VideoObject JSON-LD block:
<script type="application/ld+json">
{
"@context": "https://schema.org",
"@type": "BreadcrumbList",
"itemListElement": [
{
"@type": "ListItem",
"position": 1,
"name": "Home",
"item": "{{$config.project_url}}"
}
{{if $storage.video_view_video_view.video_id>0}}
{{if count($storage.video_view_video_view.categories)>0}}
,{
"@type": "ListItem",
"position": 2,
"name": "{{$storage.video_view_video_view.categories.0.title|json_encode|trim:'"'}}",
"item": "{{$storage.video_view_video_view.categories.0.view_page_url}}"
}
,{
"@type": "ListItem",
"position": 3,
"name": "{{$storage.video_view_video_view.title|json_encode|trim:'"'}}"
}
{{else}}
,{
"@type": "ListItem",
"position": 2,
"name": "{{$storage.video_view_video_view.title|json_encode|trim:'"'}}"
}
{{/if}}
{{elseif $storage.album_view_album_view.album_id>0}}
,{
"@type": "ListItem",
"position": 2,
"name": "{{$storage.album_view_album_view.title|json_encode|trim:'"'}}"
}
{{/if}}
]
}
</script>
This generates: Home → Category → Video Title (or Home → Album Title for albums).
This enables the sitelinks search box in Google — users can search your site directly from Google results.
Add this to include_header_general.tpl inside the <head> section (only on the homepage):
{{if $page_id=='index'}}
<script type="application/ld+json">
{
"@context": "https://schema.org",
"@type": "WebSite",
"url": "{{$config.project_url}}/",
"name": "{{$lang.project_name|json_encode|trim:'"'}}",
"potentialAction": {
"@type": "SearchAction",
"target": {
"@type": "EntryPoint",
"urlTemplate": "{{$lang.urls.search_query|replace:'%search_keyword%':'{search_term_string}'}}"
},
"query-input": "required name=search_term_string"
}
}
</script>
{{/if}}
Note: Replace the search_query URL pattern if your site uses a different search URL format. The default KVS pattern is /search/{keyword}/.
IndexNow notifies Bing and Yandex about new content immediately instead of waiting for natural crawling. Google does not support IndexNow yet.
Generate a key at bing.com/indexnow or create your own 32-character hex string.
Create a text file in your site root containing only the key:
# Example: your key is abc123def456...
# Create file: /your-site-root/abc123def456.txt
# File content: abc123def456
The easiest approach is to use the built-in custom_post_processing plugin. This automatically submits new video URLs to IndexNow as soon as processing finishes.
Back up the original file first:
cd /path/to/your/site/admin/plugins/custom_post_processing
cp custom_post_processing.php custom_post_processing.php.bak
Then replace custom_post_processing.php with this code. Change YOUR-DOMAIN.COM and YOUR_INDEXNOW_KEY to your values:
<?php
function custom_post_processingIsEnabled()
{
return true;
}
function custom_post_processingInit() {}
function custom_post_processingShow() {}
if ($_SERVER['argv'][1] == 'exec' && $_SERVER['DOCUMENT_ROOT'] == '')
{
if (file_exists('setup.php') && file_exists('functions_base.php'))
{
require_once('setup.php');
require_once('functions_base.php');
} else
{
$kvs_admin_include = dirname(dirname(dirname(__FILE__))) . '/include';
if (is_dir($kvs_admin_include))
{
$old_cwd = getcwd();
chdir($kvs_admin_include);
require_once('setup.php');
require_once('functions_base.php');
chdir($old_cwd);
} else
{
echo "Failed: cannot locate KVS admin include files";
return;
}
}
$object_type = $_SERVER['argv'][2];
$object_id = intval($_SERVER['argv'][3]);
$event_type = isset($_SERVER['argv'][4]) ? trim($_SERVER['argv'][4]) : '';
if ($object_type == 'video')
{
if ($event_type != 'new') { echo "Skipped: only new videos"; return; }
$video_data = mr2array_single(
sql_pr("SELECT * FROM {$config['tables_prefix']}videos WHERE video_id=?", $object_id)
);
if (!is_array($video_data)) { echo "Skipped: video not found"; return; }
if ($video_data['status_id'] != 1 || $video_data['is_private'] > 0 || trim($video_data['dir']) == '')
{
echo "Skipped: video not active/public";
return;
}
$website_ui = @unserialize(@file_get_contents(
"$config[project_path]/admin/data/system/website_ui_params.dat"
), ['allowed_classes' => false]);
if (!is_array($website_ui) || trim($website_ui['WEBSITE_LINK_PATTERN']) == '')
{
echo "Skipped: WEBSITE_LINK_PATTERN empty";
return;
}
$video_url = rtrim($config['project_url'], '/') . '/' . ltrim(
str_replace(
['%ID%', '%DIR%'],
[$object_id, trim($video_data['dir'])],
$website_ui['WEBSITE_LINK_PATTERN']
), '/'
);
$key = 'YOUR_INDEXNOW_KEY';
$host = parse_url($video_url, PHP_URL_HOST);
$payload = json_encode([
'host' => $host,
'key' => $key,
'keyLocation' => "https://$host/$key.txt",
'urlList' => [$video_url],
], JSON_UNESCAPED_SLASHES);
$ch = curl_init('https://api.indexnow.org/indexnow');
curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
curl_setopt($ch, CURLOPT_POST, true);
curl_setopt($ch, CURLOPT_POSTFIELDS, $payload);
curl_setopt($ch, CURLOPT_HTTPHEADER, ['Content-Type: application/json; charset=utf-8']);
curl_setopt($ch, CURLOPT_TIMEOUT, 20);
$response = curl_exec($ch);
$status = curl_getinfo($ch, CURLINFO_HTTP_CODE);
$error = $response === false ? curl_error($ch) : '';
curl_close($ch);
echo $response === false
? "IndexNow failed: $error"
: "IndexNow status=$status";
}
}
if ($_SERVER['argv'][1] == 'test' && $_SERVER['DOCUMENT_ROOT'] == '')
{
echo "OK";
}
cd /path/to/your/site/admin/include
php ../plugins/custom_post_processing/custom_post_processing.php test
php ../plugins/custom_post_processing/custom_post_processing.php exec video 12345 new
Expected output: IndexNow status=200 (accepted) or IndexNow status=202 (queued).
Important: IndexNow fires when video processing completes, even if the video is inactive or scheduled. To make this work with scheduled videos, enable "Allow inactive videos by direct URL" in KVS Settings → Website Settings.
KVS has a good robots.txt, but Google can still crawl URLs that waste your crawl budget. Adding noindex meta tags to low-value pages helps Google focus on your important content.
Add this to include_header_general.tpl right after the <meta name="keywords"> tag:
{{if $smarty.request.from > 0 || $smarty.request.from_albums > 0 || $smarty.request.from_videos > 0}}
<meta name="robots" content="noindex, follow">
{{/if}}
This adds noindex to paginated pages (page 2, 3, etc.). Google can still follow links on these pages but won't index the pages themselves, preventing "duplicate thin content" issues.
Optional: You can also noindex tag pages with very few videos by adding a condition in your tag listing template. This prevents Google from indexing hundreds of near-empty tag pages.
KVS already generates a proper video sitemap with <video:video> tags. Here are tips to make it work better:
sitemap.xmlsitemap.xml?type=videos&from_links_videos=1 — open this URL and verify video tags are presentvideos.sitemap_formats in your lang file points to a format Google can access (e.g., .mp4). If you only have .mp4 files, the default is fine.robots.txt:Sitemap: https://yourdomain.com/sitemap.xml
This is the most common issue reported by KVS webmasters. It usually means Google found the page but decided not to index it. Common causes:
noindex as described above.Google can't find the video thumbnail. Make sure your VideoObject JSON-LD includes thumbnailUrl and the URL is accessible (returns 200, not behind authentication).
Google thinks the video player is secondary. Make sure:
These waste crawl budget. Add them to robots.txt if not already blocked:
Disallow: /rss/
Disallow: /get_file/