You need to enable JavaScript to run this app.
优惠活动
大模型
产品
解决方案
定价
更多

Django对接Jellyfin视频流:长视频加载慢且无法跳转的解决方案

问题描述

基于Jellyfin服务器,用Django开发视频流应用,采用Video.js播放视频。遇到以下问题:

  • 播放时长超1小时的视频时加载极慢,看起来是完整下载后才开始播放
  • 无法跳转至视频指定位置
  • 已尝试修改Video.js的preload参数为none、metadata、auto,问题未解决
  • 怀疑Django不支持视频流功能,请求确认并给出解决方案

相关代码

views.py

def episode_detail(request, episode_id):
    user_id = request.session.get('jellyfin_user_id')
    access_token = request.session.get('jellyfin_access_token')

    if not user_id or not access_token:
        return redirect('jellyfin_login')

    # try:
    #     user = JellyfinUser.objects.get(jellyfin_user_id=user_id)
    # except JellyfinUser.DoesNotExist:
    #     return redirect('jellyfin_login')

    headers = {
        'X-Emby-Token': settings.JELLYFIN_API_KEY
    }

    episode_url = f"{settings.JELLYFIN_URL}/Users/{user_id}/Items/{episode_id}"
    episode_response = requests.get(episode_url, headers=headers)

    if episode_response.status_code != 200:
        return render(request, 'error.html', context={'error': 'Unable to fetch episode details'})

    episode = episode_response.json()

    if episode['Type'] == 'Movie':

        people = episode.get('People', [])
        if people is None:
            people = []

        genres_url = f"{settings.JELLYFIN_URL}/Genres?ParentId={episode_id}"
        genres_response = requests.get(genres_url, headers=headers)
        genres = genres_response.json().get('Items', []) if genres_response.status_code == 200 else []
        episode['Genres'] = [genre['Name'] for genre in genres]

        studio_url = f"{settings.JELLYFIN_URL}/Studios?ParentId={episode_id}"
        studio_response = requests.get(studio_url, headers=headers)
        studios = studio_response.json().get('Items', []) if studio_response.status_code == 200 else []
        episode['Studios'] = [studio['Name'] for studio in studios]

        directors = [person['Name'] for person in people if person['Type'] == 'Director']
        episode['Directors'] = directors

        actors = []
        for person in people:
            if person['Type'] == 'Actor':

                actor_image_url = f"{settings.JELLYFIN_URL}/Persons/{person['Name']}/Images/Primary"
                actor_image_url_response = requests.get(actor_image_url, headers=headers)

                if actor_image_url_response.status_code == 200:
                    person['ImageURL'] = actor_image_url
                else:
                    person['ImageURL'] = static("library/images/default/default-user.jpg")

                actor_info = {
                    'name': person['Name'],
                    'role': person['Role'],
                    'image': person['ImageURL'],
                    'id': person['Id'],
                }
                actors.append(actor_info)

        episode['Actors'] = actors

        writers = [person['Name'] for person in people if person['Type'] == 'Writer']
        episode['Writers'] = writers

        producers = [person['Name'] for person in people if person['Type'] == 'Producer']
        episode['Producers'] = producers

        similar_url = f"{settings.JELLYFIN_URL}/Items/{episode_id}/Similar"
        similar_response = requests.get(similar_url, headers=headers)
        similar_episodes = similar_response.json().get('Items', []) if similar_response.status_code == 200 else []
        for item in similar_episodes:
            item['ImageURL'] = f"{settings.JELLYFIN_URL}/Items/{item['Id']}/Images/Primary"
        episode['SimilarEpisodes'] = similar_episodes

        episode['VideoURL'] = f"{settings.JELLYFIN_URL}/Videos/{episode['Id']}/stream"
        episode['ImageURL'] = f"{settings.JELLYFIN_URL}/Items/{episode['Id']}/Images/Primary"
        episode['BackdropURL'] = f"{settings.JELLYFIN_URL}/Items/{episode['Id']}/Images/Backdrop"
        episode['CommunityRating'] = episode.get('CommunityRating', 'N/A')
        episode['Overview'] = episode.get('Overview', 'Aucune description disponible')

        episode['Duration'] = format_duration(episode['RunTimeTicks'])
            print("NO FILE")

    user_data = episode.get('UserData', {})
    if user_data is None:
        user_data = {}

    episode['IsFavorite'] = user_data.get('IsFavorite', False)

    print(episode['Type'])

    return render(request, 'episode_detail.html', context={'episode': episode})

episode_detail.html

{% extends 'base.html' %}

{% block title %} {{ episode.Name }} {% endblock %}

{% block style %}

        <!-- My Styles -->

{% endblock %}

{% block content %}

<div class="container mb-5">

    {% if episode.Type == 'Movie' %}

    <div class="row">

        <!-- AFFICHE DU FILM -->
        <div class="col-md-4">
            <img src="{{ episode.ImageURL }}" class="img-fluid movie-poster shadow-lg" alt="{{ episode.Name }}">
        </div>

        <!-- INFOS DU FILM -->
        <div class="col-md-8">

            <h1 class="text-white">{{ episode.Name }}</h1>

            <div class="d-flex align-items-center">
                <span class="production-year">{{ episode.ProductionYear }}</span>
                <span class="duration">{{ episode.Duration }}</span>
                <span class="note-commu">
                    <span>{{ episode.CommunityRating|floatformat:1 }}</span>
                    <span class="blue-star"><i class="fa-solid fa-star"></i></span>
                </span>
            </div>

            <!-- SYNOPSIS DU FILM -->
            {% if episode.Overview %}
            <div class="mt-3">
                <h4 class="section-title"><b>Synopsis</b></h4>
                <p>{{ episode.Overview }}</p>
            </div>
            {% endif %}

            <!-- DETAILS DU FILM -->
            <div class="mt-3">

                {% if episode.Genres or episode.Studios or episode.Directors or episode.Actors %}
                    <h4 class="section-title"><b>Details</b></h4>
                {% endif %}

                {% if episode.Genres %}<p><strong>Genre:</strong> {{ episode.Genres|join:", " }}</p>{% endif %}
                {% if episode.Studios %}<p><strong>Studios:</strong> {{ episode.Studios|join:", " }}</p>{% endif %}
                {% if episode.Directors %}<p><strong>Réalisateurs :</strong> {{episode.Directors|join:", " }}</p>{% endif %}
                {% if episode.Writers %}<p><strong>Scénaristes :</strong> {{episode.Writers|join:", " }}</p>{% endif %}
                {% if episode.Producers %}<p><strong>Producteurs:</strong> {{ episode.Producers|join:", " }}</p>{% endif %}
                {% if episode.Actors_name %}<p><strong>Acteurs:</strong> {{ episode.Actors_name|join:", " }}</p>{% endif %}
            </div>


            <!-- SECTION VIDEO DU FILM -->
            <div class="mt-5">
                <video
                        id="my-movie-video"
                        class="video-js vjs-big-play-centered vjs-fluid vjs-fill"
                        controls
                        preload="none"
                        poster="{{ episode.BackdropURL }}"
                        data-setup="{}"
                >
                    <source src="{{ episode.VideoURL }}" type="video/mp4" />
                    <source src="{{ episode.VideoURL }}" type="video/webm" />
                    <source src="{{ episode.VideoURL }}" type="video/avi" />
                    <source src="{{ episode.VideoURL }}" type="video/mkv" />
                    <p class="vjs-no-js">
                        To view this video please enable JavaScript, and consider upgrading to a
                        web browser that
                        supports HTML5 video
                    </p>
                </video>
            </div>

        </div>
    </div>
</div>

{% endblock %}
解决方案

Django支持视频流的确认

Django本身完全支持视频流,问题出在视频交付的实现方式上。流媒体核心是HTTP范围请求(Range Requests):浏览器通过请求视频的分段内容,实现边播边下和跳转,只要正确处理该请求即可。

问题根源

  1. 前端Video.js配置重复,多个source标签用同一个URL但不同类型,导致浏览器无法正确识别视频格式,影响流媒体能力
  2. 直接暴露Jellyfin的Stream URL时,若存在跨域或请求头未正确传递,Jellyfin可能返回完整视频而非分段内容
  3. 未在Django层(若代理请求)处理Range头转发

具体修复步骤

1. 前端Video.js优化

  • 移除重复source标签,仅保留视频实际对应的格式(比如视频是MP4就只留video/mp4)
  • 添加crossorigin="anonymous"属性,确保跨域请求时能正确传递Range头
  • 调整preload为metadata,仅加载视频元数据而非全部内容:
<video
        id="my-movie-video"
        class="video-js vjs-big-play-centered vjs-fluid vjs-fill"
        controls
        preload="metadata"
        poster="{{ episode.BackdropURL }}"
        crossorigin="anonymous"
        data-setup="{}"
>
    <source src="{{ episode.VideoURL }}" type="video/mp4" />
    <p class="vjs-no-js">
        请启用JavaScript并升级到支持HTML5视频的浏览器以观看此视频
    </p>
</video>

2. 验证Jellyfin的Range请求支持

用curl测试Jellyfin的Stream URL是否支持分段请求:

curl -I -H "Range: bytes=0-1023" "你的Jellyfin视频Stream URL"

若返回206 Partial Content状态码,说明Jellyfin配置正常;若返回200 OK,需检查Jellyfin后台是否启用了流媒体功能。

3. Django代理视频请求(可选,用于权限校验或安全隔离)

如果需要在Django层做权限校验,可实现代理视图转发Range请求:

from django.http import StreamingHttpResponse
import requests

def proxy_video(request, episode_id):
    user_id = request.session.get('jellyfin_user_id')
    access_token = request.session.get('jellyfin_access_token')
    if not user_id or not access_token:
        return redirect('jellyfin_login')

    headers = {
        'X-Emby-Token': settings.JELLYFIN_API_KEY
    }
    # 转发Range头
    if 'HTTP_RANGE' in request.META:
        headers['Range'] = request.META['HTTP_RANGE']

    video_url = f"{settings.JELLYFIN_URL}/Videos/{episode_id}/stream"
    response = requests.get(video_url, headers=headers, stream=True)

    # 保留关键响应头
    response_headers = {}
    for key, value in response.headers.items():
        if key.lower() in ['content-type', 'content-length', 'content-range', 'accept-ranges']:
            response_headers[key] = value

    return StreamingHttpResponse(
        response.iter_content(chunk_size=8192),
        status=response.status_code,
        headers=response_headers
    )

添加路由:

path('video/<str:episode_id>/', views.proxy_video, name='proxy_video'),

修改episode['VideoURL']为代理地址:

episode['VideoURL'] = reverse('proxy_video', args=[episode['Id']])

4. 视频文件优化

确保视频是可流式传输的格式(如MP4的MOOV原子在文件开头),可通过ffmpeg重新编码:

ffmpeg -i input.mp4 -movflags faststart output.mp4

内容的提问来源于stack exchange,提问作者Abdoulaye

相关产品推荐
方舟 Agent Plan

超全模态模型 × Harness 升级,最新支持 Deepseek-V4.1-Flash、GLM-5.3 系列、Doubao-Seedream-5.0-pro、Kimi-K3 (部分), 限时 9.9 元起

最近更新时间:2026.06.19 23:07:02