Skip to content

Commit 05e49e5

Browse files
committed
增强 OPDS 旧客户端下载兼容性
1 parent d222e41 commit 05e49e5

9 files changed

Lines changed: 64 additions & 18 deletions

File tree

docs/API.md

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -367,7 +367,8 @@ GET /api/comics?readingStatus=finished
367367
| GET | `/api/opds/search.xml` | OpenSearch 搜索描述 |
368368
| GET | `/api/opds/search` | OPDS 搜索 |
369369
| GET | `/api/opds/cover/:id` | OPDS 漫画封面 |
370-
| GET/HEAD | `/api/opds/download/:id` | 下载原始文件,支持字节范围请求 |
370+
| GET/HEAD | `/api/opds/download/:id/:filename` | 下载原始文件,支持字节范围请求 |
371+
| GET/HEAD | `/api/opds/download/:id` | 兼容旧版下载地址 |
371372
| GET | `/api/recommendations` | 个性化推荐 |
372373
| GET | `/api/recommendations/similar/:id` | 相似推荐 |
373374
| POST | `/api/recommendations/ai-reasons` | AI 推荐理由 |
@@ -381,6 +382,7 @@ GET /api/comics?readingStatus=finished
381382
- **内容范围**:只返回 `Comic.type=comic`、所属 `Library.type=comic` 且书库已启用的内容。小说不会出现在目录、搜索、最近更新或收藏中。
382383
- **文件格式**:CBZ/ZIP、CBR/RAR、CB7/7Z 和 PDF。EPUB、TXT 等小说格式不进入 OPDS。
383384
- **分段下载**:下载接口支持 `HEAD` 和 HTTP Range。合法范围请求返回 `206 Partial Content``Content-Range` 与分段 `Content-Length`,便于客户端读取大型 CBZ/PDF 的尾部目录。
385+
- **下载发现**:Feed 中的 acquisition URL 以经过转义的真实文件名结尾,并通过 Atom `length` 属性提供文件字节数。旧版不带文件名的下载地址继续可用。
384386
- **媒体类型**:CBZ/ZIP 使用标准媒体类型 `application/vnd.comicbook+zip`;CBR/RAR、CB7/7Z 和 PDF 分别使用对应的漫画归档或 PDF 媒体类型。
385387
- **权限**:OPDS 是获取目录,只返回当前用户拥有 `canDownload` 权限的书库内容。公开书库或仅有 `canView` 权限不会自动获得 OPDS 下载权限。
386388
- **合集导航**:根目录包含 `/api/opds/series` 入口。该接口返回 `kind=navigation`,每个合集链接到 `/api/opds/series/:id` 获取 Feed;合集内按现有篇章和成员顺序扁平排列,篇章名会作为条目标题前缀。

docs/api_doc.html

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -5750,20 +5750,21 @@ <h4>Response <span class="status-code">200</span></h4>
57505750
</div>
57515751
</div>
57525752

5753-
<div class="endpoint auth-required" data-search="GET HEAD /api/opds/download/:id OPDS comic file range download" id="get-api-opds-download-id">
5753+
<div class="endpoint auth-required" data-search="GET HEAD /api/opds/download/:id/:filename OPDS comic file range download" id="get-api-opds-download-id">
57545754
<div class="endpoint-header">
57555755
<span class="method method-GET">GET</span>
5756-
<span class="path">/api/opds/download/:id</span>
5756+
<span class="path">/api/opds/download/:id/:filename</span>
57575757
<span class="summary">OPDS comic file download</span>
57585758
</div>
57595759
<div class="endpoint-body">
5760-
<p class="description">Downloads a supported comic file with permission checks. Supports HEAD and HTTP byte ranges for large-file and ZIP central-directory access.</p>
5760+
<p class="description">Downloads a supported comic file with permission checks. Feed links include the escaped source filename and Atom length metadata for older clients. The filename-free path remains available as a legacy alias. Supports HEAD and HTTP byte ranges.</p>
57615761

57625762
<h4>Path Parameters</h4>
57635763
<table class="params-table">
57645764
<thead><tr><th>Name</th><th>Type</th><th>Required</th><th>Description</th></tr></thead>
57655765
<tbody>
57665766
<tr><td>id</td><td>string</td><td>Yes</td><td>Comic ID</td></tr>
5767+
<tr><td>filename</td><td>string</td><td>Yes</td><td>Escaped source filename used for client format detection</td></tr>
57675768
</tbody>
57685769
</table>
57695770

frontend/public/api-doc.html

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -6163,20 +6163,21 @@ <h4><span data-en="Response" data-zh="响应">Response</span> <span class="statu
61636163
</div>
61646164
</div>
61656165

6166-
<div class="endpoint auth-required" data-search="GET HEAD /api/opds/download/:id OPDS comic file range download" id="get-api-opds-download-id">
6166+
<div class="endpoint auth-required" data-search="GET HEAD /api/opds/download/:id/:filename OPDS comic file range download" id="get-api-opds-download-id">
61676167
<div class="endpoint-header">
61686168
<span class="method method-GET">GET</span>
6169-
<span class="path">/api/opds/download/:id</span>
6169+
<span class="path">/api/opds/download/:id/:filename</span>
61706170
<span class="summary"><span data-en="OPDS comic file download" data-zh="OPDS漫画文件下载">OPDS comic file download</span></span>
61716171
</div>
61726172
<div class="endpoint-body">
6173-
<p class="description"><span data-en="Downloads a supported comic publication after rechecking content type, enabled comic library membership, and canDownload permission. Supports HEAD and HTTP byte ranges for large-file and ZIP central-directory access. Novel and unsupported-format IDs return 404; missing download permission returns 403." data-zh="重新检查内容类型、已启用漫画书库归属和 canDownload 权限后下载受支持的漫画文件。支持 HEAD 和 HTTP 字节范围请求,便于客户端读取大文件及 ZIP 中央目录。小说和不支持格式的 ID 返回 404;缺少下载权限返回 403">Downloads a supported comic publication after rechecking content type, enabled comic library membership, and canDownload permission. Supports HEAD and HTTP byte ranges for large-file and ZIP central-directory access. Novel and unsupported-format IDs return 404; missing download permission returns 403.</span></p>
6173+
<p class="description"><span data-en="Downloads a supported comic publication after rechecking content type, enabled comic library membership, and canDownload permission. Feed links include the escaped source filename and Atom length metadata for older clients; /api/opds/download/:id remains available as a legacy alias. Supports HEAD and HTTP byte ranges." data-zh="重新检查内容类型、已启用漫画书库归属和 canDownload 权限后下载受支持的漫画文件。Feed 链接包含转义后的源文件名和 Atom length 字节数,以兼容旧客户端;/api/opds/download/:id 继续作为旧版别名。支持 HEAD 和 HTTP 字节范围请求">Downloads a supported comic publication after rechecking content type, enabled comic library membership, and canDownload permission. Feed links include the escaped source filename and Atom length metadata for older clients; /api/opds/download/:id remains available as a legacy alias. Supports HEAD and HTTP byte ranges.</span></p>
61746174

61756175
<h4><span data-en="Path Parameters" data-zh="路径参数">Path Parameters</span></h4>
61766176
<table class="params-table">
61776177
<thead><tr><th><span data-en="Name" data-zh="名称">Name</span></th><th><span data-en="Type" data-zh="类型">Type</span></th><th><span data-en="Required" data-zh="必填">Required</span></th><th><span data-en="Description" data-zh="说明">Description</span></th></tr></thead>
61786178
<tbody>
61796179
<tr><td>id</td><td>string</td><td>Yes</td><td>Comic ID</td></tr>
6180+
<tr><td>filename</td><td>string</td><td>Yes</td><td>Escaped source filename used for client format detection</td></tr>
61806181
</tbody>
61816182
</table>
61826183

internal/handler/opds_handler.go

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -402,6 +402,7 @@ func toOPDSComics(rows []store.OPDSComicRow) []service.OPDSComic {
402402
Publisher: row.Publisher,
403403
Year: row.Year,
404404
PageCount: row.PageCount,
405+
FileSize: row.FileSize,
405406
AddedAt: row.AddedAt,
406407
UpdatedAt: row.UpdatedAt,
407408
Tags: row.Tags,

internal/handler/opds_handler_test.go

Lines changed: 9 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -131,7 +131,8 @@ func TestOPDSDownloadSupportsFullRangeAndHeadRequests(t *testing.T) {
131131
t.Fatalf("SetUserLibraryAccess failed: %v", err)
132132
}
133133

134-
full := performOPDSRequest(router, http.MethodGet, "/api/opds/download/opds-range-comic", user.Username, token, nil)
134+
downloadPath := "/api/opds/download/opds-range-comic/Range%20Comic.cbz"
135+
full := performOPDSRequest(router, http.MethodGet, downloadPath, user.Username, token, nil)
135136
if full.Code != http.StatusOK {
136137
t.Fatalf("full download returned %d: %s", full.Code, full.Body.String())
137138
}
@@ -153,7 +154,7 @@ func TestOPDSDownloadSupportsFullRangeAndHeadRequests(t *testing.T) {
153154

154155
const tailSize = 22
155156
rangeHeader := map[string]string{"Range": "bytes=-22"}
156-
partial := performOPDSRequest(router, http.MethodGet, "/api/opds/download/opds-range-comic", user.Username, token, rangeHeader)
157+
partial := performOPDSRequest(router, http.MethodGet, downloadPath, user.Username, token, rangeHeader)
157158
if partial.Code != http.StatusPartialContent {
158159
t.Fatalf("range download returned %d: %s", partial.Code, partial.Body.String())
159160
}
@@ -165,7 +166,7 @@ func TestOPDSDownloadSupportsFullRangeAndHeadRequests(t *testing.T) {
165166
t.Fatal("range response does not contain the requested ZIP tail")
166167
}
167168

168-
head := performOPDSRequest(router, http.MethodHead, "/api/opds/download/opds-range-comic", user.Username, token, nil)
169+
head := performOPDSRequest(router, http.MethodHead, downloadPath, user.Username, token, nil)
169170
if head.Code != http.StatusOK {
170171
t.Fatalf("HEAD download returned %d: %s", head.Code, head.Body.String())
171172
}
@@ -175,6 +176,11 @@ func TestOPDSDownloadSupportsFullRangeAndHeadRequests(t *testing.T) {
175176
if got := head.Header().Get("Content-Length"); got != strconv.Itoa(len(content)) {
176177
t.Fatalf("HEAD Content-Length = %q, want %d", got, len(content))
177178
}
179+
180+
legacy := performOPDSRequest(router, http.MethodGet, "/api/opds/download/opds-range-comic", user.Username, token, nil)
181+
if legacy.Code != http.StatusOK || !bytes.Equal(legacy.Body.Bytes(), content) {
182+
t.Fatalf("legacy filename-free download failed with status %d", legacy.Code)
183+
}
178184
}
179185

180186
func TestOPDSSeriesFeedsAreDownloadScopedAndFlattenSections(t *testing.T) {

internal/handler/routes_metadata.go

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -119,6 +119,8 @@ func registerMetadataRoutes(api *gin.RouterGroup) {
119119
opdsGroup.GET("/cover/:id", opds.Cover)
120120
opdsGroup.GET("/download/:id", opds.Download)
121121
opdsGroup.HEAD("/download/:id", opds.Download)
122+
opdsGroup.GET("/download/:id/:filename", opds.Download)
123+
opdsGroup.HEAD("/download/:id/:filename", opds.Download)
122124
}
123125

124126
// Recommendations — require auth

internal/service/opds.go

Lines changed: 28 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ package service
33
import (
44
"encoding/xml"
55
"fmt"
6+
"net/url"
67
"path/filepath"
78
"strconv"
89
"strings"
@@ -31,6 +32,7 @@ type OPDSComic struct {
3132
Publisher string
3233
Year int
3334
PageCount int
35+
FileSize int64
3436
AddedAt string
3537
UpdatedAt string
3638
Tags []string
@@ -96,10 +98,11 @@ type atomAuthor struct {
9698
}
9799

98100
type atomLink struct {
99-
Rel string `xml:"rel,attr"`
100-
Href string `xml:"href,attr"`
101-
Type string `xml:"type,attr,omitempty"`
102-
Title string `xml:"title,attr,omitempty"`
101+
Rel string `xml:"rel,attr"`
102+
Href string `xml:"href,attr"`
103+
Type string `xml:"type,attr,omitempty"`
104+
Title string `xml:"title,attr,omitempty"`
105+
Length string `xml:"length,attr,omitempty"`
103106
}
104107

105108
type atomEntry struct {
@@ -271,7 +274,12 @@ func GenerateAcquisitionFeed(opts OPDSAcquisitionFeedOptions) string {
271274
Links: []atomLink{
272275
{Rel: "http://opds-spec.org/image", Href: absoluteOPDSURL(opts.BaseURL, "/api/opds/cover/"+comic.ID)},
273276
{Rel: "http://opds-spec.org/image/thumbnail", Href: absoluteOPDSURL(opts.BaseURL, "/api/opds/cover/"+comic.ID)},
274-
{Rel: "http://opds-spec.org/acquisition", Href: absoluteOPDSURL(opts.BaseURL, "/api/opds/download/"+comic.ID), Type: mimeType},
277+
{
278+
Rel: "http://opds-spec.org/acquisition",
279+
Href: absoluteOPDSURL(opts.BaseURL, opdsDownloadPath(comic.ID, comic.Filename)),
280+
Type: mimeType,
281+
Length: opdsFileLength(comic.FileSize),
282+
},
275283
},
276284
Language: strings.TrimSpace(comic.Language),
277285
Publisher: strings.TrimSpace(comic.Publisher),
@@ -327,6 +335,21 @@ func GenerateAcquisitionFeed(opts OPDSAcquisitionFeedOptions) string {
327335
return marshalOPDSXML(feed)
328336
}
329337

338+
func opdsDownloadPath(comicID, filename string) string {
339+
name := filepath.Base(strings.TrimSpace(filename))
340+
if name == "" || name == "." {
341+
return "/api/opds/download/" + comicID
342+
}
343+
return "/api/opds/download/" + comicID + "/" + url.PathEscape(name)
344+
}
345+
346+
func opdsFileLength(fileSize int64) string {
347+
if fileSize <= 0 {
348+
return ""
349+
}
350+
return strconv.FormatInt(fileSize, 10)
351+
}
352+
330353
func appendOPDSPaginationLinks(feed *atomFeed, baseURL string, pagination OPDSPagination, feedType string) {
331354
links := []struct {
332355
rel string

internal/service/opds_test.go

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -56,6 +56,7 @@ func TestAcquisitionFeedMetadataPaginationAndLinks(t *testing.T) {
5656
Publisher: "Publisher",
5757
Year: 2025,
5858
PageCount: 42,
59+
FileSize: 123456,
5960
AddedAt: "2025-01-02T03:04:05Z",
6061
UpdatedAt: "2025-02-03T04:05:06Z",
6162
Tags: []string{"Drama", "Complete"},
@@ -85,7 +86,7 @@ func TestAcquisitionFeedMetadataPaginationAndLinks(t *testing.T) {
8586
`rel="next"`,
8687
`rel="previous"`,
8788
`href="http://example.test/api/opds/cover/comic-1"`,
88-
`href="http://example.test/api/opds/download/comic-1" type="application/vnd.comicbook+zip"`,
89+
`href="http://example.test/api/opds/download/comic-1/comic.cbz" type="application/vnd.comicbook+zip" length="123456"`,
8990
`rel="collection" href="http://example.test/api/opds/series/series-1" type="` + OPDSAcquisitionMIME + `" title="Series One"`,
9091
`<dcterms:language>zh-CN</dcterms:language>`,
9192
`<dcterms:publisher>Publisher</dcterms:publisher>`,
@@ -196,6 +197,14 @@ func TestOPDSAcquisitionMIMEForFilename(t *testing.T) {
196197
}
197198
}
198199

200+
func TestOPDSDownloadPathIncludesEscapedBasename(t *testing.T) {
201+
got := opdsDownloadPath("comic-1", "Series/Comic 01 [中文].cbz")
202+
want := "/api/opds/download/comic-1/Comic%2001%20%5B%E4%B8%AD%E6%96%87%5D.cbz"
203+
if got != want {
204+
t.Fatalf("opdsDownloadPath() = %q, want %q", got, want)
205+
}
206+
}
207+
199208
func assertValidXML(t *testing.T, value string) {
200209
t.Helper()
201210
decoder := xml.NewDecoder(strings.NewReader(value))

internal/store/comic_query.go

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -974,6 +974,7 @@ type OPDSComicRow struct {
974974
Publisher string
975975
Year int
976976
PageCount int
977+
FileSize int64
977978
AddedAt string
978979
UpdatedAt string
979980
Tags []string
@@ -1102,7 +1103,7 @@ func GetOPDSComics(opts OPDSQueryOptions) ([]OPDSComicRow, int, error) {
11021103
}
11031104
query := fmt.Sprintf(`
11041105
SELECT c."id", c."title", c."author", c."description", c."language",
1105-
c."genre", c."publisher", c."year", c."pageCount",
1106+
c."genre", c."publisher", c."year", c."pageCount", c."fileSize",
11061107
c."addedAt", c."updatedAt", c."filename",
11071108
COALESCE(cs."id", ''), COALESCE(cs."title", ''),
11081109
COALESCE(css."title", ''), COALESCE(csi."displayLabel", '')
@@ -1129,7 +1130,7 @@ func GetOPDSComics(opts OPDSQueryOptions) ([]OPDSComicRow, int, error) {
11291130

11301131
if err := rows.Scan(
11311132
&c.ID, &c.Title, &c.Author, &c.Description, &c.Language,
1132-
&c.Genre, &c.Publisher, &year, &c.PageCount,
1133+
&c.Genre, &c.Publisher, &year, &c.PageCount, &c.FileSize,
11331134
&addedAt, &updatedAt, &c.Filename, &c.SeriesID, &c.SeriesTitle,
11341135
&c.SectionTitle, &c.DisplayLabel,
11351136
); err != nil {

0 commit comments

Comments
 (0)