Add commit message generation

This commit is contained in:
ShukeBta
2026-05-28 14:55:01 +08:00
parent f553674b71
commit e93eb40f4e
5 changed files with 170 additions and 88 deletions
+4
View File
@@ -79,6 +79,10 @@ func embyWithRequestAddress(c *gin.Context, payload map[string]any) map[string]a
if address := embyRequestBaseURL(c); address != "" { if address := embyRequestBaseURL(c); address != "" {
out["LocalAddress"] = address out["LocalAddress"] = address
out["WanAddress"] = address out["WanAddress"] = address
out["PublishedServerUrl"] = address
}
if strings.HasPrefix(strings.ToLower(c.Request.URL.Path), "/emby") {
out["ProductName"] = "Emby Server"
} }
return out return out
} }
+22 -7
View File
@@ -128,35 +128,50 @@ func historyContinueHandler(svc *service.Container) gin.HandlerFunc {
// historyDeleteHandler removes one or all history rows for the caller. // historyDeleteHandler removes one or all history rows for the caller.
// //
// DELETE /api/watch-history?media_id=xxx → delete just that media's row // DELETE /api/watch-history?media_id=xxx → delete just that media's row
// DELETE /api/watch-history → clear all rows for the user // DELETE /api/watch-history?status=completed → delete completed rows
// DELETE /api/watch-history?status=incomplete → delete unfinished rows
// DELETE /api/watch-history → clear all rows for the user
func historyDeleteHandler(svc *service.Container) gin.HandlerFunc { func historyDeleteHandler(svc *service.Container) gin.HandlerFunc {
return func(c *gin.Context) { return func(c *gin.Context) {
uid, _ := c.Get(middleware.CtxUserID) uid, _ := c.Get(middleware.CtxUserID)
userID := toString(uid) userID := toString(uid)
mediaID := c.Query("media_id") mediaID := c.Query("media_id")
status := c.Query("status")
q := svc.Repo.DB.Where("user_id = ?", userID) q := svc.Repo.DB.Where("user_id = ?", userID)
if mediaID != "" { if mediaID != "" {
q = q.Where("media_id = ?", mediaID) q = q.Where("media_id = ?", mediaID)
} }
if err := q.Delete(&model.PlaybackHistory{}).Error; err != nil { switch status {
case "completed", "watched":
q = q.Where("completed = ?", true)
case "incomplete", "unfinished", "unwatched":
q = q.Where("completed = ?", false)
case "":
default:
c.JSON(http.StatusBadRequest, gin.H{"error": "status must be completed or incomplete"})
return
}
res := q.Delete(&model.PlaybackHistory{})
if err := res.Error; err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return return
} }
c.Status(http.StatusNoContent) c.JSON(http.StatusOK, gin.H{"removed": res.RowsAffected})
} }
} }
func historyDeleteOneHandler(svc *service.Container) gin.HandlerFunc { func historyDeleteOneHandler(svc *service.Container) gin.HandlerFunc {
return func(c *gin.Context) { return func(c *gin.Context) {
uid, _ := c.Get(middleware.CtxUserID) uid, _ := c.Get(middleware.CtxUserID)
if err := svc.Repo.DB. res := svc.Repo.DB.
Where("user_id = ? AND id = ?", toString(uid), c.Param("id")). Where("user_id = ? AND id = ?", toString(uid), c.Param("id")).
Delete(&model.PlaybackHistory{}).Error; err != nil { Delete(&model.PlaybackHistory{})
if err := res.Error; err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return return
} }
c.Status(http.StatusNoContent) c.JSON(http.StatusOK, gin.H{"removed": res.RowsAffected})
} }
} }
+64 -64
View File
@@ -48,17 +48,17 @@ func NewEmbyService(cfg *config.Config, log *zap.Logger, repo *repository.Contai
// SystemInfo returns the full Emby identity payload. // SystemInfo returns the full Emby identity payload.
func (e *EmbyService) SystemInfo() map[string]any { func (e *EmbyService) SystemInfo() map[string]any {
return map[string]any{ return map[string]any{
"Id": embyServerID, "Id": embyServerID,
"ServerId": embyServerID, "ServerId": embyServerID,
"ServerName": "MediaStationGo", "ServerName": "MediaStationGo",
"Version": "10.8.13", "Version": "10.8.13",
"ProductName": "Jellyfin Server", "ProductName": "Jellyfin Server",
"OperatingSystem": "Windows", "OperatingSystem": "Windows",
"Architecture": "X64", "Architecture": "X64",
"LocalAddress": "", "LocalAddress": "",
"WanAddress": "", "WanAddress": "",
"HasPendingRestart": false, "HasPendingRestart": false,
"IsShuttingDown": false, "IsShuttingDown": false,
"SupportsLibraryMonitor": true, "SupportsLibraryMonitor": true,
"SupportsHttps": false, "SupportsHttps": false,
"SupportsAutoDiscovery": true, "SupportsAutoDiscovery": true,
@@ -84,10 +84,10 @@ func (e *EmbyService) SystemInfoPublic() map[string]any {
"OperatingSystem": "Windows", "OperatingSystem": "Windows",
"LocalAddress": "", "LocalAddress": "",
"WanAddress": "", "WanAddress": "",
"HttpServerPortNumber": e.cfg.App.Port, "HttpServerPortNumber": e.cfg.App.Port,
"HttpsPortNumber": 0, "HttpsPortNumber": 0,
"SupportsHttps": false, "SupportsHttps": false,
"SupportsAutoDiscovery": true, "SupportsAutoDiscovery": true,
"StartupWizardCompleted": true, "StartupWizardCompleted": true,
} }
} }
@@ -129,33 +129,33 @@ func (e *EmbyService) userPayload(u *model.User) map[string]any {
"LastLoginDate": u.LastLoginAt, "LastLoginDate": u.LastLoginAt,
"LastActivityDate": u.UpdatedAt, "LastActivityDate": u.UpdatedAt,
"Configuration": map[string]any{ "Configuration": map[string]any{
"PlayDefaultAudioTrack": true, "PlayDefaultAudioTrack": true,
"DisplayCollectionsView": true, "DisplayCollectionsView": true,
"DisplayMissingEpisodes": false, "DisplayMissingEpisodes": false,
"SubtitleMode": "Default", "SubtitleMode": "Default",
"EnableNextEpisodeAutoPlay": true, "EnableNextEpisodeAutoPlay": true,
"AudioLanguagePreference": "", "AudioLanguagePreference": "",
"SubtitleLanguagePreference": "", "SubtitleLanguagePreference": "",
}, },
"Policy": map[string]any{ "Policy": map[string]any{
"IsAdministrator": u.Role == "admin", "IsAdministrator": u.Role == "admin",
"IsHidden": false, "IsHidden": false,
"IsDisabled": !u.IsActive, "IsDisabled": !u.IsActive,
"EnableUserPreferenceAccess": true, "EnableUserPreferenceAccess": true,
"EnableRemoteAccess": true, "EnableRemoteAccess": true,
"EnableMediaPlayback": true, "EnableMediaPlayback": true,
"EnableAudioPlaybackTranscoding": true, "EnableAudioPlaybackTranscoding": true,
"EnableVideoPlaybackTranscoding": true, "EnableVideoPlaybackTranscoding": true,
"EnablePlaybackRemuxing": true, "EnablePlaybackRemuxing": true,
"EnableLiveTvAccess": false, "EnableLiveTvAccess": false,
"EnableContentDownloading": true, "EnableContentDownloading": true,
"EnableSyncTranscoding": true, "EnableSyncTranscoding": true,
"EnableMediaConversion": true, "EnableMediaConversion": true,
"EnableAllChannels": true, "EnableAllChannels": true,
"EnableAllFolders": true, "EnableAllFolders": true,
"EnableAllDevices": true, "EnableAllDevices": true,
"AuthenticationProviderId": "Emby.Server.Implementations.LocalAuthenticationProvider", "AuthenticationProviderId": "Emby.Server.Implementations.LocalAuthenticationProvider",
"PasswordResetProviderId": "Emby.Server.Implementations.LocalPasswordResetProvider", "PasswordResetProviderId": "Emby.Server.Implementations.LocalPasswordResetProvider",
}, },
} }
} }
@@ -186,13 +186,13 @@ func (e *EmbyService) libraryAsView(l *model.Library) map[string]any {
collectionType = "music" collectionType = "music"
} }
return map[string]any{ return map[string]any{
"Id": l.ID, "Id": l.ID,
"Name": l.Name, "Name": l.Name,
"CollectionType": collectionType, "CollectionType": collectionType,
"ServerId": embyServerID, "ServerId": embyServerID,
"Type": "CollectionFolder", "Type": "CollectionFolder",
"IsFolder": true, "IsFolder": true,
"ImageTags": map[string]string{}, "ImageTags": map[string]string{},
"BackdropImageTags": []string{}, "BackdropImageTags": []string{},
"UserData": map[string]any{ "UserData": map[string]any{
"PlaybackPositionTicks": 0, "PlaybackPositionTicks": 0,
@@ -208,16 +208,16 @@ func (e *EmbyService) libraryAsView(l *model.Library) map[string]any {
// ItemsParams 是 /Items 与 /Users/{uid}/Items 共用的查询参数。 // ItemsParams 是 /Items 与 /Users/{uid}/Items 共用的查询参数。
type ItemsParams struct { type ItemsParams struct {
UserID string UserID string
ParentID string ParentID string
IDs []string IDs []string
SearchTerm string SearchTerm string
IncludeItemTypes []string IncludeItemTypes []string
Recursive bool Recursive bool
SortBy string SortBy string
SortOrder string SortOrder string
Limit int Limit int
StartIndex int StartIndex int
} }
// Items paginates media in Emby's flat shape. // Items paginates media in Emby's flat shape.
@@ -507,15 +507,15 @@ func (e *EmbyService) mediaStreams(m *model.Media) []map[string]any {
streams := []map[string]any{} streams := []map[string]any{}
if m.VideoCodec != "" || m.Width > 0 { if m.VideoCodec != "" || m.Width > 0 {
streams = append(streams, map[string]any{ streams = append(streams, map[string]any{
"Codec": m.VideoCodec, "Codec": m.VideoCodec,
"Type": "Video", "Type": "Video",
"Index": 0, "Index": 0,
"Width": m.Width, "Width": m.Width,
"Height": m.Height, "Height": m.Height,
"AspectRatio": "", "AspectRatio": "",
"IsDefault": true, "IsDefault": true,
"IsForced": false, "IsForced": false,
"IsExternal": false, "IsExternal": false,
"DisplayTitle": fmt.Sprintf("%dx%d %s", m.Width, m.Height, m.VideoCodec), "DisplayTitle": fmt.Sprintf("%dx%d %s", m.Width, m.Height, m.VideoCodec),
}) })
} }
+5 -2
View File
@@ -19,10 +19,13 @@ export const historyAPI = {
}) })
.then((r) => r.data), .then((r) => r.data),
clear: (mediaID?: string) => clear: (mediaID?: string, status?: 'completed' | 'incomplete') =>
api api
.delete('/watch-history', { .delete('/watch-history', {
params: mediaID ? { media_id: mediaID } : {}, params: {
...(mediaID ? { media_id: mediaID } : {}),
...(status ? { status } : {}),
},
}) })
.then((r) => r.data), .then((r) => r.data),
+75 -15
View File
@@ -1,9 +1,11 @@
import { useEffect, useState } from 'react' import { useEffect, useState } from 'react'
import { Link } from 'react-router-dom' import { Link } from 'react-router-dom'
import { Clock, Play } from 'lucide-react' import { Clock, Play, Trash2 } from 'lucide-react'
import toast from 'react-hot-toast'
import { playbackAPI, type HistoryItem } from '../api/playback' import { historyAPI } from '../api/history'
import { imageURL } from '../api/client' import { imageURL } from '../api/client'
import type { HistoryItem } from '../types'
function fmtDuration(ms: number): string { function fmtDuration(ms: number): string {
if (!ms || ms <= 0) return '—' if (!ms || ms <= 0) return '—'
@@ -18,19 +20,66 @@ function fmtDuration(ms: number): string {
export function WatchHistoryPage() { export function WatchHistoryPage() {
const [items, setItems] = useState<HistoryItem[]>([]) const [items, setItems] = useState<HistoryItem[]>([])
const [loading, setLoading] = useState(true) const [loading, setLoading] = useState(true)
const [busy, setBusy] = useState('')
useEffect(() => { const load = () => {
playbackAPI setLoading(true)
.recentHistory() historyAPI
.list(200)
.then(setItems) .then(setItems)
.finally(() => setLoading(false)) .finally(() => setLoading(false))
}, []) }
useEffect(() => { load() }, [])
const removeOne = async (id: string) => {
if (!confirm('确定移除此条观看历史?不会删除媒体文件。')) return
setBusy(id)
try {
await historyAPI.remove(id)
setItems((prev) => prev.filter((item) => item.id !== id))
toast.success('已移除观看历史')
} finally {
setBusy('')
}
}
const clearByStatus = async (status: 'completed' | 'incomplete') => {
const label = status === 'completed' ? '已看完' : '未看完'
if (!confirm(`确定清除所有${label}的观看历史?不会删除媒体文件。`)) return
setBusy(status)
try {
await historyAPI.clear(undefined, status)
setItems((prev) => prev.filter((item) => status === 'completed' ? !item.completed : item.completed))
toast.success(`已清除${label}记录`)
} finally {
setBusy('')
}
}
return ( return (
<div className="space-y-6"> <div className="space-y-6">
<header className="flex items-center gap-3"> <header className="flex flex-wrap items-center justify-between gap-3">
<Clock className="h-6 w-6 text-brand-500" /> <div className="flex items-center gap-3">
<h1 className="font-display text-3xl font-bold text-ink-600">观看历史</h1> <Clock className="h-6 w-6 text-brand-500" />
<h1 className="font-display text-3xl font-bold text-ink-600">观看历史</h1>
</div>
<div className="flex flex-wrap items-center gap-2">
<button
onClick={() => clearByStatus('incomplete')}
disabled={busy !== '' || items.every((item) => item.completed)}
className="rounded-xl border border-gray-200 bg-white px-3 py-2 text-xs font-bold text-gray-600 shadow-sm transition hover:border-amber-300 hover:text-amber-600 disabled:cursor-not-allowed disabled:opacity-45"
>
清除未看完
</button>
<button
onClick={() => clearByStatus('completed')}
disabled={busy !== '' || items.every((item) => !item.completed)}
className="rounded-xl border border-gray-200 bg-white px-3 py-2 text-xs font-bold text-gray-600 shadow-sm transition hover:border-emerald-300 hover:text-emerald-600 disabled:cursor-not-allowed disabled:opacity-45"
>
清除已看完
</button>
</div>
</header> </header>
{loading && <p className="text-sand-500">加载中…</p>} {loading && <p className="text-sand-500">加载中…</p>}
@@ -82,12 +131,23 @@ export function WatchHistoryPage() {
/> />
</div> </div>
</div> </div>
<Link <div className="flex shrink-0 items-center gap-2">
to={`/play/${m.id}`} <Link
className="neon-button !px-3 !py-1 !text-xs" to={`/play/${m.id}`}
> className="neon-button !px-3 !py-1 !text-xs"
<Play size={12} /> 继续 >
</Link> <Play size={12} /> 继续
</Link>
<button
onClick={() => removeOne(h.id)}
disabled={busy === h.id}
className="inline-flex items-center gap-1 rounded-xl border border-gray-200 bg-white px-3 py-1.5 text-xs font-bold text-gray-500 transition hover:border-red-200 hover:bg-red-50 hover:text-red-500 disabled:opacity-50"
title="移除此条观看历史"
>
<Trash2 size={12} />
移除
</button>
</div>
</div> </div>
) )
})} })}