package app import ( "archive/zip" "bytes" "context" "crypto/hmac" "crypto/rand" "crypto/sha256" "encoding/hex" "errors" "fmt" "math" "mime" "net/http" "net/url" "os" "path/filepath" "strconv" "strings" "time" ) func (app *UIApp) embySigningKey(create bool) ([]byte, error) { app.embyMu.Lock() defer app.embyMu.Unlock() if len(app.embyKey) == 32 { return app.embyKey, nil } path := filepath.Join(app.cfg.dataDirectory(), "emby-key") body, err := os.ReadFile(path) if errors.Is(err, os.ErrNotExist) && create { if err = os.MkdirAll(filepath.Dir(path), 0o755); err != nil { return nil, err } body = make([]byte, 32) if _, err = rand.Read(body); err != nil { return nil, err } file, openErr := os.OpenFile(path, os.O_WRONLY|os.O_CREATE|os.O_EXCL, 0o600) if errors.Is(openErr, os.ErrExist) { body, err = os.ReadFile(path) } else if openErr != nil { return nil, openErr } else { _, err = file.Write(body) if syncErr := file.Sync(); err == nil { err = syncErr } if closeErr := file.Close(); err == nil { err = closeErr } } } if err != nil { return nil, err } if len(body) != 32 { return nil, errors.New("Emby 链接密钥无效") } app.embyKey = body return body, nil } func embyToken(key []byte, dramaID, chapterID string, accounts ...string) string { mac := hmac.New(sha256.New, key) payload := "emby-v1\x00" + dramaID + "\x00" + chapterID if len(accounts) > 0 && accounts[0] != "" { payload = "emby-v2\x00" + accounts[0] + "\x00" + dramaID + "\x00" + chapterID } mac.Write([]byte(payload)) return hex.EncodeToString(mac.Sum(nil)) } func validEmbyIdentity(dramaID, chapterID string) bool { canonical, _, ok := playbackHistoryIdentity(dramaID) return ok && canonical == dramaID && len(dramaID) <= 256 && chapterID != "" && len(chapterID) <= 512 && !strings.ContainsAny(chapterID, "\x00\r\n") } func (app *UIApp) authorizeEmby(writer http.ResponseWriter, request *http.Request) (string, string, bool) { writer.Header().Set("Cache-Control", "no-store") writer.Header().Set("X-Content-Type-Options", "nosniff") if request.Method != http.MethodGet && request.Method != http.MethodHead { writer.Header().Set("Allow", "GET, HEAD") writeJSON(writer, http.StatusMethodNotAllowed, map[string]string{"error": "请求方法不支持"}) return "", "", false } query := request.URL.Query() id, chapter := query.Get("id"), query.Get("chapter") supplied, err := hex.DecodeString(query.Get("key")) key, keyErr := app.embySigningKey(false) if err != nil || len(supplied) != 32 || keyErr != nil || !validEmbyIdentity(id, chapter) { writeJSON(writer, http.StatusForbidden, map[string]string{"error": "Emby 播放链接无效,请重新导出"}) return "", "", false } owner := query.Get("account") expected, _ := hex.DecodeString(embyToken(key, id, chapter, owner)) if !hmac.Equal(expected, supplied) { writeJSON(writer, http.StatusForbidden, map[string]string{"error": "Emby 播放链接无效,请重新导出"}) return "", "", false } if owner != "" && !app.embyAccountSourceAllowed(owner, id) { writeViewerError(writer, http.StatusForbidden, "export_forbidden", "当前账号无权使用此 Emby 链接,请联系管理员") return "", "", false } return id, chapter, true } func embyBaseURL(raw string) (string, error) { parsed, err := url.Parse(strings.TrimSpace(raw)) if err != nil || len(raw) > 2048 || parsed == nil || parsed.Host == "" || parsed.User != nil || parsed.RawQuery != "" || parsed.Fragment != "" || (parsed.Scheme != "http" && parsed.Scheme != "https") || strings.ContainsAny(raw, "\x00\r\n") { return "", errors.New("请输入 Emby 能访问的剧库 HTTP/HTTPS 地址") } return strings.TrimRight(parsed.String(), "/"), nil } func (app *UIApp) handleEmbyExport(writer http.ResponseWriter, request *http.Request) { var input struct { DramaID string `json:"dramaId"` BaseURL string `json:"baseUrl"` } if !readPlaybackRequest(writer, request, &input) { return } if !app.requireDramaSources(writer, request, []string{input.DramaID}) { return } base, err := embyBaseURL(input.BaseURL) if err != nil { writeJSON(writer, http.StatusBadRequest, map[string]string{"error": err.Error()}) return } app.mu.Lock() var drama Drama for _, item := range app.dramas { if item.ID == input.DramaID { drama = item break } } app.mu.Unlock() if drama.ID == "" { writeJSON(writer, http.StatusNotFound, map[string]string{"error": "请先在剧库中找到此剧"}) return } ctx, cancel := context.WithTimeout(request.Context(), 90*time.Second) defer cancel() title, chapters, err := app.downloader.GetDramaChapters(ctx, drama.ID) if err != nil { writeJSON(writer, http.StatusBadGateway, map[string]string{"error": "读取分集失败:" + app.redactError(err)}) return } if len(chapters) == 0 || len(chapters) > 2000 { writeJSON(writer, http.StatusBadGateway, map[string]string{"error": "可导出分集数无效"}) return } for _, chapter := range chapters { if !validEmbyIdentity(drama.ID, chapter.ID) { writeJSON(writer, http.StatusBadGateway, map[string]string{"error": "此剧未提供稳定分集 ID,暂不能导出"}) return } } key, err := app.embySigningKey(true) if err != nil { writeJSON(writer, http.StatusInternalServerError, map[string]string{"error": "无法保存 Emby 链接密钥"}) return } drama.Title = firstNonEmpty(title, drama.DisplayTitle()) owner := "" if scope := sourceScope(request.Context()); scope != nil { owner = scope.AccountID } body, err := buildEmbyArchive(drama, chapters, base, key, owner) if err != nil { writeJSON(writer, http.StatusInternalServerError, map[string]string{"error": "生成 Emby 分集文件失败"}) return } writer.Header().Set("Content-Type", "application/zip") writer.Header().Set("Content-Disposition", mime.FormatMediaType("attachment", map[string]string{"filename": safeFilename(drama.DisplayTitle()) + "-Emby.zip"})) writer.Header().Set("Content-Length", strconv.Itoa(len(body))) writer.Write(body) } func buildEmbyArchive(drama Drama, chapters []Chapter, base string, key []byte, accounts ...string) ([]byte, error) { owner := "" if len(accounts) > 0 { owner = accounts[0] } records, err := embyChapterRecords(drama.ID, chapters, nil) if err != nil { return nil, err } var buffer bytes.Buffer archive := zip.NewWriter(&buffer) prefix := embyFolderName(drama) + "/" err = writeEmbyFiles(drama, records, base, key, owner, func(name string, body []byte) error { file, err := archive.Create(prefix + name) if err != nil { return err } _, err = file.Write(body) return err }) if err != nil { archive.Close() return nil, err } if err = archive.Close(); err != nil { return nil, err } return buffer.Bytes(), nil } func (app *UIApp) embyTask(ctx context.Context, id, chapterID string) (Task, string, error) { app.mu.Lock() for _, candidate := range app.tasks { if candidate.DramaID == id && candidate.Source.Chapter.ID == chapterID && isPlayableDownloadTask(candidate) { task, downloadID := candidate.Source, candidate.ID app.mu.Unlock() return task, downloadID, nil } } app.mu.Unlock() title, chapters, err := app.downloader.GetDramaChapters(ctx, id) if err != nil { return Task{}, "", err } for index, chapter := range chapters { if chapter.ID == chapterID { return Task{DramaID: id, DramaTitle: title, Chapter: chapter, Index: index + 1, Total: len(chapters)}, "", nil } } return Task{}, "", errors.New("此分集已不可用,请更新剧库并重新导出") } func (app *UIApp) handleEmbyLegacyStream(writer http.ResponseWriter, request *http.Request) { id, chapter, ok := app.authorizeEmby(writer, request) if !ok { return } if request.Method == http.MethodHead { writer.Header().Set("Content-Type", "application/vnd.apple.mpegurl") return } session := app.acquireEmbyPlayback(writer, request, id, chapter, "", true) if session == nil { return } defer app.releaseEmbyPlayback(session) app.serveEmbyPlaylist(writer, request, session) } func (app *UIApp) serveEmbyPlaylist(writer http.ResponseWriter, request *http.Request, session *playbackSession) { id, chapter, sessionID, duration := session.dramaID, session.tasks[0].Chapter.ID, session.id, session.duration var playlist strings.Builder fmt.Fprintf(&playlist, "#EXTM3U\n#EXT-X-VERSION:3\n#EXT-X-TARGETDURATION:%d\n#EXT-X-MEDIA-SEQUENCE:0\n#EXT-X-PLAYLIST-TYPE:VOD\n#EXT-X-INDEPENDENT-SEGMENTS\n", playbackNativeSegmentSeconds) for part, count := 0, int(math.Ceil(duration/playbackNativeSegmentSeconds)); part < count; part++ { query := url.Values{"id": {id}, "chapter": {chapter}, "key": {request.URL.Query().Get("key")}, "session": {sessionID}, "segment": {strconv.Itoa(part)}} if owner := request.URL.Query().Get("account"); owner != "" { query.Set("account", owner) } fmt.Fprintf(&playlist, "#EXTINF:%.6f,\nsegment.ts?%s\n", math.Min(playbackNativeSegmentSeconds, duration-float64(part*playbackNativeSegmentSeconds)), query.Encode()) } playlist.WriteString("#EXT-X-ENDLIST\n") writer.Header().Set("Content-Type", "application/vnd.apple.mpegurl") http.ServeContent(writer, request, "index.m3u8", time.Time{}, strings.NewReader(playlist.String())) } func (app *UIApp) handleEmbySegment(writer http.ResponseWriter, request *http.Request) { id, chapter, ok := app.authorizeEmby(writer, request) if !ok { return } query := request.URL.Query() part, err := strconv.Atoi(query.Get("segment")) if err != nil || part < 0 { writeJSON(writer, http.StatusBadRequest, map[string]string{"error": "分片编号无效"}) return } if query.Get("session") == "" { app.writeEmbyPlaybackError(writer, request, http.StatusGone, "播放已过期,请重新播放") return } session := app.acquireEmbyPlayback(writer, request, id, chapter, query.Get("session"), true) if session == nil { return } defer app.releaseEmbyPlayback(session) cache := session.native if cache == nil { app.writeEmbyPlaybackError(writer, request, http.StatusGone, "播放格式已变化,请重新播放") return } ctx, cancel := context.WithTimeout(request.Context(), 90*time.Second) defer cancel() body, err := cache.segment(ctx, part) if err != nil { app.writeEmbyPlaybackError(writer, request, http.StatusBadGateway, "读取 Emby 分片失败:"+app.redactError(err)) return } active := &playbackRunContext{id: session.id, run: session.run, session: session, ctx: cache.ctx} writer = &playbackActivityWriter{ResponseWriter: writer, app: app, run: active} writer.Header().Set("Content-Type", "video/mp2t") http.ServeContent(writer, request, "segment.ts", time.Time{}, bytes.NewReader(body)) }