From e93eb40f4e89bf30308fa24d49a15bd765383ad7 Mon Sep 17 00:00:00 2001 From: ShukeBta Date: Thu, 28 May 2026 14:55:01 +0800 Subject: [PATCH] Add commit message generation --- internal/handler/emby.go | 4 + internal/handler/watch_history.go | 29 +++++-- internal/service/emby_compat.go | 128 ++++++++++++++--------------- web/src/api/history.ts | 7 +- web/src/pages/WatchHistoryPage.tsx | 90 ++++++++++++++++---- 5 files changed, 170 insertions(+), 88 deletions(-) diff --git a/internal/handler/emby.go b/internal/handler/emby.go index bd2b8ac..a2c91bd 100644 --- a/internal/handler/emby.go +++ b/internal/handler/emby.go @@ -79,6 +79,10 @@ func embyWithRequestAddress(c *gin.Context, payload map[string]any) map[string]a if address := embyRequestBaseURL(c); address != "" { out["LocalAddress"] = address out["WanAddress"] = address + out["PublishedServerUrl"] = address + } + if strings.HasPrefix(strings.ToLower(c.Request.URL.Path), "/emby") { + out["ProductName"] = "Emby Server" } return out } diff --git a/internal/handler/watch_history.go b/internal/handler/watch_history.go index 565a7ec..9b86392 100644 --- a/internal/handler/watch_history.go +++ b/internal/handler/watch_history.go @@ -128,35 +128,50 @@ func historyContinueHandler(svc *service.Container) gin.HandlerFunc { // 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 → clear all rows for the user +// DELETE /api/watch-history?media_id=xxx → delete just that media's row +// 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 { return func(c *gin.Context) { uid, _ := c.Get(middleware.CtxUserID) userID := toString(uid) mediaID := c.Query("media_id") + status := c.Query("status") q := svc.Repo.DB.Where("user_id = ?", userID) if 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()}) return } - c.Status(http.StatusNoContent) + c.JSON(http.StatusOK, gin.H{"removed": res.RowsAffected}) } } func historyDeleteOneHandler(svc *service.Container) gin.HandlerFunc { return func(c *gin.Context) { uid, _ := c.Get(middleware.CtxUserID) - if err := svc.Repo.DB. + res := svc.Repo.DB. 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()}) return } - c.Status(http.StatusNoContent) + c.JSON(http.StatusOK, gin.H{"removed": res.RowsAffected}) } } diff --git a/internal/service/emby_compat.go b/internal/service/emby_compat.go index d917a2c..fe8c544 100644 --- a/internal/service/emby_compat.go +++ b/internal/service/emby_compat.go @@ -48,17 +48,17 @@ func NewEmbyService(cfg *config.Config, log *zap.Logger, repo *repository.Contai // SystemInfo returns the full Emby identity payload. func (e *EmbyService) SystemInfo() map[string]any { return map[string]any{ - "Id": embyServerID, - "ServerId": embyServerID, - "ServerName": "MediaStationGo", - "Version": "10.8.13", - "ProductName": "Jellyfin Server", - "OperatingSystem": "Windows", - "Architecture": "X64", - "LocalAddress": "", - "WanAddress": "", - "HasPendingRestart": false, - "IsShuttingDown": false, + "Id": embyServerID, + "ServerId": embyServerID, + "ServerName": "MediaStationGo", + "Version": "10.8.13", + "ProductName": "Jellyfin Server", + "OperatingSystem": "Windows", + "Architecture": "X64", + "LocalAddress": "", + "WanAddress": "", + "HasPendingRestart": false, + "IsShuttingDown": false, "SupportsLibraryMonitor": true, "SupportsHttps": false, "SupportsAutoDiscovery": true, @@ -84,10 +84,10 @@ func (e *EmbyService) SystemInfoPublic() map[string]any { "OperatingSystem": "Windows", "LocalAddress": "", "WanAddress": "", - "HttpServerPortNumber": e.cfg.App.Port, - "HttpsPortNumber": 0, - "SupportsHttps": false, - "SupportsAutoDiscovery": true, + "HttpServerPortNumber": e.cfg.App.Port, + "HttpsPortNumber": 0, + "SupportsHttps": false, + "SupportsAutoDiscovery": true, "StartupWizardCompleted": true, } } @@ -129,33 +129,33 @@ func (e *EmbyService) userPayload(u *model.User) map[string]any { "LastLoginDate": u.LastLoginAt, "LastActivityDate": u.UpdatedAt, "Configuration": map[string]any{ - "PlayDefaultAudioTrack": true, - "DisplayCollectionsView": true, - "DisplayMissingEpisodes": false, - "SubtitleMode": "Default", - "EnableNextEpisodeAutoPlay": true, - "AudioLanguagePreference": "", - "SubtitleLanguagePreference": "", + "PlayDefaultAudioTrack": true, + "DisplayCollectionsView": true, + "DisplayMissingEpisodes": false, + "SubtitleMode": "Default", + "EnableNextEpisodeAutoPlay": true, + "AudioLanguagePreference": "", + "SubtitleLanguagePreference": "", }, "Policy": map[string]any{ - "IsAdministrator": u.Role == "admin", - "IsHidden": false, - "IsDisabled": !u.IsActive, - "EnableUserPreferenceAccess": true, - "EnableRemoteAccess": true, - "EnableMediaPlayback": true, + "IsAdministrator": u.Role == "admin", + "IsHidden": false, + "IsDisabled": !u.IsActive, + "EnableUserPreferenceAccess": true, + "EnableRemoteAccess": true, + "EnableMediaPlayback": true, "EnableAudioPlaybackTranscoding": true, "EnableVideoPlaybackTranscoding": true, - "EnablePlaybackRemuxing": true, - "EnableLiveTvAccess": false, - "EnableContentDownloading": true, - "EnableSyncTranscoding": true, - "EnableMediaConversion": true, - "EnableAllChannels": true, - "EnableAllFolders": true, - "EnableAllDevices": true, - "AuthenticationProviderId": "Emby.Server.Implementations.LocalAuthenticationProvider", - "PasswordResetProviderId": "Emby.Server.Implementations.LocalPasswordResetProvider", + "EnablePlaybackRemuxing": true, + "EnableLiveTvAccess": false, + "EnableContentDownloading": true, + "EnableSyncTranscoding": true, + "EnableMediaConversion": true, + "EnableAllChannels": true, + "EnableAllFolders": true, + "EnableAllDevices": true, + "AuthenticationProviderId": "Emby.Server.Implementations.LocalAuthenticationProvider", + "PasswordResetProviderId": "Emby.Server.Implementations.LocalPasswordResetProvider", }, } } @@ -186,13 +186,13 @@ func (e *EmbyService) libraryAsView(l *model.Library) map[string]any { collectionType = "music" } return map[string]any{ - "Id": l.ID, - "Name": l.Name, - "CollectionType": collectionType, - "ServerId": embyServerID, - "Type": "CollectionFolder", - "IsFolder": true, - "ImageTags": map[string]string{}, + "Id": l.ID, + "Name": l.Name, + "CollectionType": collectionType, + "ServerId": embyServerID, + "Type": "CollectionFolder", + "IsFolder": true, + "ImageTags": map[string]string{}, "BackdropImageTags": []string{}, "UserData": map[string]any{ "PlaybackPositionTicks": 0, @@ -208,16 +208,16 @@ func (e *EmbyService) libraryAsView(l *model.Library) map[string]any { // ItemsParams 是 /Items 与 /Users/{uid}/Items 共用的查询参数。 type ItemsParams struct { - UserID string - ParentID string - IDs []string - SearchTerm string - IncludeItemTypes []string - Recursive bool - SortBy string - SortOrder string - Limit int - StartIndex int + UserID string + ParentID string + IDs []string + SearchTerm string + IncludeItemTypes []string + Recursive bool + SortBy string + SortOrder string + Limit int + StartIndex int } // 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{} if m.VideoCodec != "" || m.Width > 0 { streams = append(streams, map[string]any{ - "Codec": m.VideoCodec, - "Type": "Video", - "Index": 0, - "Width": m.Width, - "Height": m.Height, - "AspectRatio": "", - "IsDefault": true, - "IsForced": false, - "IsExternal": false, + "Codec": m.VideoCodec, + "Type": "Video", + "Index": 0, + "Width": m.Width, + "Height": m.Height, + "AspectRatio": "", + "IsDefault": true, + "IsForced": false, + "IsExternal": false, "DisplayTitle": fmt.Sprintf("%dx%d %s", m.Width, m.Height, m.VideoCodec), }) } diff --git a/web/src/api/history.ts b/web/src/api/history.ts index 8c3a0ac..4cff061 100644 --- a/web/src/api/history.ts +++ b/web/src/api/history.ts @@ -19,10 +19,13 @@ export const historyAPI = { }) .then((r) => r.data), - clear: (mediaID?: string) => + clear: (mediaID?: string, status?: 'completed' | 'incomplete') => api .delete('/watch-history', { - params: mediaID ? { media_id: mediaID } : {}, + params: { + ...(mediaID ? { media_id: mediaID } : {}), + ...(status ? { status } : {}), + }, }) .then((r) => r.data), diff --git a/web/src/pages/WatchHistoryPage.tsx b/web/src/pages/WatchHistoryPage.tsx index 128586c..31d41a4 100644 --- a/web/src/pages/WatchHistoryPage.tsx +++ b/web/src/pages/WatchHistoryPage.tsx @@ -1,9 +1,11 @@ import { useEffect, useState } from 'react' 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 type { HistoryItem } from '../types' function fmtDuration(ms: number): string { if (!ms || ms <= 0) return '—' @@ -18,19 +20,66 @@ function fmtDuration(ms: number): string { export function WatchHistoryPage() { const [items, setItems] = useState([]) const [loading, setLoading] = useState(true) + const [busy, setBusy] = useState('') - useEffect(() => { - playbackAPI - .recentHistory() + const load = () => { + setLoading(true) + historyAPI + .list(200) .then(setItems) .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 (
-
- -

观看历史

+
+
+ +

观看历史

+
+
+ + +
{loading &&

加载中…

} @@ -82,12 +131,23 @@ export function WatchHistoryPage() { />
- - 继续 - +
+ + 继续 + + +
) })}