← All Guides

KVS SEO Optimization

Complete guide to improving search engine indexing for KVS sites

1. What KVS Already Has

Before making any changes, it's important to understand that KVS already includes solid SEO foundations out of the box:

  • Dynamic XML Sitemap with video sitemap support (video:video tags including thumbnail, content URL, duration, rating, tags)
  • Schema.org VideoObject JSON-LD on video pages (name, description, thumbnailUrl, contentUrl, duration, interactionStatistic)
  • Open Graph meta tags (og:title, og:image, og:description, og:type with video-specific tags)
  • Twitter Card support (player type for video pages)
  • Canonical URLs and hreflang for multilingual sites
  • Clean URL structure with proper 301 redirects
  • robots.txt blocking AJAX endpoints, admin pages, and internal URLs
  • SEO text fields for every page type in admin panel

The improvements below add what's missing — additional structured data, faster indexing, and better crawl budget management.

2. Enhanced Schema.org JSON-LD

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 Google
  • interactionType as proper objects ({"@type": "WatchAction"}) instead of plain strings — follows current Schema.org spec
  • genre field from the first category
  • isFamilyFriendly flag

BreadcrumbList 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).

4. WebSite + SearchAction Schema

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}/.

5. IndexNow — Instant Indexing

IndexNow notifies Bing and Yandex about new content immediately instead of waiting for natural crawling. Google does not support IndexNow yet.

Step 1: Get an API Key

Generate a key at bing.com/indexnow or create your own 32-character hex string.

Step 2: Place the Key File

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

Step 3: Auto-Submit via KVS Plugin

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";
}

Step 4: Test

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.

6. Robots Meta Tags

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.

7. Video Sitemap Tips

KVS already generates a proper video sitemap with <video:video> tags. Here are tips to make it work better:

  • Submit it in Google Search Console: Go to Sitemaps → Add a new sitemap → enter sitemap.xml
  • Check video sitemap specifically: Your video URLs are at sitemap.xml?type=videos&from_links_videos=1 — open this URL and verify video tags are present
  • KVS 6.4.0+: If you're on 6.4.0 or newer, video file URLs in sitemaps are stable and accessible to bots. This is a significant improvement over older versions.
  • Sitemap format setting: In KVS admin, check that videos.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.
  • Add sitemap reference to robots.txt: If not already there, add this line to your robots.txt:
Sitemap: https://yourdomain.com/sitemap.xml

8. Common Indexing Issues

"Crawled — currently not indexed"

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:

  • Thin content: Video pages with very short or no descriptions. Write unique descriptions for your videos — even 2-3 sentences help.
  • Duplicate content: Multiple pages with similar titles/descriptions. Use unique titles and don't repeat the same description template across all videos.
  • Site authority: New sites need time. Keep adding quality content and building backlinks.
  • Crawl budget waste: Too many low-quality pages (empty tags, pagination). Use noindex as described above.

"No thumbnail URL provided"

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).

"Video is not the main content of the page"

Google thinks the video player is secondary. Make sure:

  • The video player is above the fold (visible without scrolling)
  • The page title matches the video title
  • There's not too much unrelated content above the player

Internal URLs in Google index (/rss/, /get_file/)

These waste crawl budget. Add them to robots.txt if not already blocked:

Disallow: /rss/
Disallow: /get_file/