package handlers import ( "net/http" "path/filepath" "seanime/internal/core" util "seanime/internal/util/proxies" "strings" "time" "github.com/google/uuid" "github.com/labstack/echo/v4" "github.com/labstack/echo/v4/middleware" "github.com/rs/zerolog" "github.com/ziflex/lecho/v3" ) type Handler struct { App *core.App } func InitRoutes(app *core.App, e *echo.Echo) { // CORS middleware e.Use(middleware.CORSWithConfig(middleware.CORSConfig{ AllowOrigins: []string{"*"}, AllowHeaders: []string{"Origin", "Content-Type", "Accept", "Cookie", "Authorization", "X-Seanime-Token", "X-Seanime-Nakama-Token", "X-Seanime-Nakama-Username", "X-Seanime-Nakama-Server-Version", "X-Seanime-Nakama-Peer-Id"}, AllowCredentials: true, })) lechoLogger := lecho.From(*app.Logger) urisToSkip := []string{ "/internal/metrics", "/_next", "/icons", "/events", "/api/v1/image-proxy", "/api/v1/mediastream/transcode/", "/api/v1/torrent-client/list", "/api/v1/proxy", } // Logging middleware e.Use(lecho.Middleware(lecho.Config{ Logger: lechoLogger, Skipper: func(c echo.Context) bool { path := c.Request().URL.RequestURI() if filepath.Ext(c.Request().URL.Path) == ".txt" || filepath.Ext(c.Request().URL.Path) == ".png" || filepath.Ext(c.Request().URL.Path) == ".ico" { return true } for _, uri := range urisToSkip { if uri == path || strings.HasPrefix(path, uri) { return true } } return false }, Enricher: func(c echo.Context, logger zerolog.Context) zerolog.Context { // Add which file the request came from return logger.Str("file", c.Path()) }, })) // Recovery middleware e.Use(middleware.Recover()) // Client ID middleware e.Use(func(next echo.HandlerFunc) echo.HandlerFunc { return func(c echo.Context) error { // Check if the client has a UUID cookie cookie, err := c.Cookie("Seanime-Client-Id") if err != nil || cookie.Value == "" { // Generate a new UUID for the client u := uuid.New().String() // Create a cookie with the UUID newCookie := new(http.Cookie) newCookie.Name = "Seanime-Client-Id" newCookie.Value = u newCookie.HttpOnly = false // Make the cookie accessible via JS newCookie.Expires = time.Now().Add(24 * time.Hour) newCookie.Path = "/" newCookie.Domain = "" newCookie.SameSite = http.SameSiteDefaultMode newCookie.Secure = false // Set the cookie c.SetCookie(newCookie) // Store the UUID in the context for use in the request c.Set("Seanime-Client-Id", u) } else { // Store the existing UUID in the context for use in the request c.Set("Seanime-Client-Id", cookie.Value) } return next(c) } }) e.Use(headMethodMiddleware) h := &Handler{App: app} e.GET("/events", h.webSocketEventHandler) v1 := e.Group("/api").Group("/v1") // Commented out for now, will be used later // // Auth middleware // v1.Use(h.OptionalAuthMiddleware) imageProxy := &util.ImageProxy{} v1.GET("/image-proxy", imageProxy.ProxyImage) v1.GET("/proxy", util.VideoProxy) v1.HEAD("/proxy", util.VideoProxy) v1.GET("/status", h.HandleGetStatus) v1.GET("/log/*", h.HandleGetLogContent) v1.GET("/logs/filenames", h.HandleGetLogFilenames) v1.DELETE("/logs", h.HandleDeleteLogs) v1.GET("/logs/latest", h.HandleGetLatestLogContent) v1.GET("/memory/stats", h.HandleGetMemoryStats) v1.GET("/memory/profile", h.HandleGetMemoryProfile) v1.GET("/memory/goroutine", h.HandleGetGoRoutineProfile) v1.GET("/memory/cpu", h.HandleGetCPUProfile) v1.POST("/memory/gc", h.HandleForceGC) v1.POST("/announcements", h.HandleGetAnnouncements) // Auth v1.POST("/auth/login", h.HandleLogin) v1.POST("/auth/logout", h.HandleLogout) // Settings v1.GET("/settings", h.HandleGetSettings) v1.PATCH("/settings", h.HandleSaveSettings) v1.POST("/start", h.HandleGettingStarted) v1.PATCH("/settings/auto-downloader", h.HandleSaveAutoDownloaderSettings) // Auto Downloader v1.POST("/auto-downloader/run", h.HandleRunAutoDownloader) v1.GET("/auto-downloader/rule/:id", h.HandleGetAutoDownloaderRule) v1.GET("/auto-downloader/rule/anime/:id", h.HandleGetAutoDownloaderRulesByAnime) v1.GET("/auto-downloader/rules", h.HandleGetAutoDownloaderRules) v1.POST("/auto-downloader/rule", h.HandleCreateAutoDownloaderRule) v1.PATCH("/auto-downloader/rule", h.HandleUpdateAutoDownloaderRule) v1.DELETE("/auto-downloader/rule/:id", h.HandleDeleteAutoDownloaderRule) v1.GET("/auto-downloader/items", h.HandleGetAutoDownloaderItems) v1.DELETE("/auto-downloader/item", h.HandleDeleteAutoDownloaderItem) // Other v1.POST("/test-dump", h.HandleTestDump) v1.POST("/directory-selector", h.HandleDirectorySelector) v1.POST("/open-in-explorer", h.HandleOpenInExplorer) v1.POST("/media-player/start", h.HandleStartDefaultMediaPlayer) // // AniList // v1Anilist := v1.Group("/anilist") v1Anilist.GET("/collection", h.HandleGetAnimeCollection) v1Anilist.POST("/collection", h.HandleGetAnimeCollection) v1Anilist.GET("/collection/raw", h.HandleGetRawAnimeCollection) v1Anilist.POST("/collection/raw", h.HandleGetRawAnimeCollection) v1Anilist.GET("/media-details/:id", h.HandleGetAnilistAnimeDetails) v1Anilist.GET("/studio-details/:id", h.HandleGetAnilistStudioDetails) v1Anilist.POST("/list-entry", h.HandleEditAnilistListEntry) v1Anilist.DELETE("/list-entry", h.HandleDeleteAnilistListEntry) v1Anilist.POST("/list-anime", h.HandleAnilistListAnime) v1Anilist.POST("/list-recent-anime", h.HandleAnilistListRecentAiringAnime) v1Anilist.GET("/list-missed-sequels", h.HandleAnilistListMissedSequels) v1Anilist.GET("/stats", h.HandleGetAniListStats) // // MAL // v1.POST("/mal/auth", h.HandleMALAuth) v1.POST("/mal/logout", h.HandleMALLogout) // // Library // v1Library := v1.Group("/library") v1Library.POST("/scan", h.HandleScanLocalFiles) v1Library.DELETE("/empty-directories", h.HandleRemoveEmptyDirectories) v1Library.GET("/local-files", h.HandleGetLocalFiles) v1Library.POST("/local-files", h.HandleLocalFileBulkAction) v1Library.PATCH("/local-files", h.HandleUpdateLocalFiles) v1Library.DELETE("/local-files", h.HandleDeleteLocalFiles) v1Library.GET("/local-files/dump", h.HandleDumpLocalFilesToFile) v1Library.POST("/local-files/import", h.HandleImportLocalFiles) v1Library.PATCH("/local-file", h.HandleUpdateLocalFileData) v1Library.GET("/collection", h.HandleGetLibraryCollection) v1Library.GET("/schedule", h.HandleGetAnimeCollectionSchedule) v1Library.GET("/scan-summaries", h.HandleGetScanSummaries) v1Library.GET("/missing-episodes", h.HandleGetMissingEpisodes) v1Library.GET("/anime-entry/:id", h.HandleGetAnimeEntry) v1Library.POST("/anime-entry/suggestions", h.HandleFetchAnimeEntrySuggestions) v1Library.POST("/anime-entry/manual-match", h.HandleAnimeEntryManualMatch) v1Library.PATCH("/anime-entry/bulk-action", h.HandleAnimeEntryBulkAction) v1Library.POST("/anime-entry/open-in-explorer", h.HandleOpenAnimeEntryInExplorer) v1Library.POST("/anime-entry/update-progress", h.HandleUpdateAnimeEntryProgress) v1Library.POST("/anime-entry/update-repeat", h.HandleUpdateAnimeEntryRepeat) v1Library.GET("/anime-entry/silence/:id", h.HandleGetAnimeEntrySilenceStatus) v1Library.POST("/anime-entry/silence", h.HandleToggleAnimeEntrySilenceStatus) v1Library.POST("/unknown-media", h.HandleAddUnknownMedia) // // Anime // v1.GET("/anime/episode-collection/:id", h.HandleGetAnimeEpisodeCollection) // // Torrent / Torrent Client // v1.POST("/torrent/search", h.HandleSearchTorrent) v1.POST("/torrent-client/download", h.HandleTorrentClientDownload) v1.GET("/torrent-client/list", h.HandleGetActiveTorrentList) v1.POST("/torrent-client/action", h.HandleTorrentClientAction) v1.POST("/torrent-client/rule-magnet", h.HandleTorrentClientAddMagnetFromRule) // // Download // v1.POST("/download-torrent-file", h.HandleDownloadTorrentFile) // // Updates // v1.GET("/latest-update", h.HandleGetLatestUpdate) v1.GET("/changelog", h.HandleGetChangelog) v1.POST("/install-update", h.HandleInstallLatestUpdate) v1.POST("/download-release", h.HandleDownloadRelease) // // Theme // v1.GET("/theme", h.HandleGetTheme) v1.PATCH("/theme", h.HandleUpdateTheme) // // Playback Manager // v1.POST("/playback-manager/sync-current-progress", h.HandlePlaybackSyncCurrentProgress) v1.POST("/playback-manager/start-playlist", h.HandlePlaybackStartPlaylist) v1.POST("/playback-manager/playlist-next", h.HandlePlaybackPlaylistNext) v1.POST("/playback-manager/cancel-playlist", h.HandlePlaybackCancelCurrentPlaylist) v1.POST("/playback-manager/next-episode", h.HandlePlaybackPlayNextEpisode) v1.GET("/playback-manager/next-episode", h.HandlePlaybackGetNextEpisode) v1.POST("/playback-manager/autoplay-next-episode", h.HandlePlaybackAutoPlayNextEpisode) v1.POST("/playback-manager/play", h.HandlePlaybackPlayVideo) v1.POST("/playback-manager/play-random", h.HandlePlaybackPlayRandomVideo) //------------ v1.POST("/playback-manager/manual-tracking/start", h.HandlePlaybackStartManualTracking) v1.POST("/playback-manager/manual-tracking/cancel", h.HandlePlaybackCancelManualTracking) // // Playlists // v1.GET("/playlists", h.HandleGetPlaylists) v1.POST("/playlist", h.HandleCreatePlaylist) v1.PATCH("/playlist", h.HandleUpdatePlaylist) v1.DELETE("/playlist", h.HandleDeletePlaylist) v1.GET("/playlist/episodes/:id/:progress", h.HandleGetPlaylistEpisodes) // // Onlinestream // v1.POST("/onlinestream/episode-source", h.HandleGetOnlineStreamEpisodeSource) v1.POST("/onlinestream/episode-list", h.HandleGetOnlineStreamEpisodeList) v1.DELETE("/onlinestream/cache", h.HandleOnlineStreamEmptyCache) v1.POST("/onlinestream/search", h.HandleOnlinestreamManualSearch) v1.POST("/onlinestream/manual-mapping", h.HandleOnlinestreamManualMapping) v1.POST("/onlinestream/get-mapping", h.HandleGetOnlinestreamMapping) v1.POST("/onlinestream/remove-mapping", h.HandleRemoveOnlinestreamMapping) // // Metadata Provider // v1.POST("/metadata-provider/filler", h.HandlePopulateFillerData) v1.DELETE("/metadata-provider/filler", h.HandleRemoveFillerData) // // Manga // v1Manga := v1.Group("/manga") v1Manga.POST("/anilist/collection", h.HandleGetAnilistMangaCollection) v1Manga.GET("/anilist/collection/raw", h.HandleGetRawAnilistMangaCollection) v1Manga.POST("/anilist/collection/raw", h.HandleGetRawAnilistMangaCollection) v1Manga.POST("/anilist/list", h.HandleAnilistListManga) v1Manga.GET("/collection", h.HandleGetMangaCollection) v1Manga.GET("/latest-chapter-numbers", h.HandleGetMangaLatestChapterNumbersMap) v1Manga.POST("/refetch-chapter-containers", h.HandleRefetchMangaChapterContainers) v1Manga.GET("/entry/:id", h.HandleGetMangaEntry) v1Manga.GET("/entry/:id/details", h.HandleGetMangaEntryDetails) v1Manga.DELETE("/entry/cache", h.HandleEmptyMangaEntryCache) v1Manga.POST("/chapters", h.HandleGetMangaEntryChapters) v1Manga.POST("/pages", h.HandleGetMangaEntryPages) v1Manga.POST("/update-progress", h.HandleUpdateMangaProgress) v1Manga.GET("/downloaded-chapters/:id", h.HandleGetMangaEntryDownloadedChapters) v1Manga.GET("/downloads", h.HandleGetMangaDownloadsList) v1Manga.POST("/download-chapters", h.HandleDownloadMangaChapters) v1Manga.POST("/download-data", h.HandleGetMangaDownloadData) v1Manga.DELETE("/download-chapter", h.HandleDeleteMangaDownloadedChapters) v1Manga.GET("/download-queue", h.HandleGetMangaDownloadQueue) v1Manga.POST("/download-queue/start", h.HandleStartMangaDownloadQueue) v1Manga.POST("/download-queue/stop", h.HandleStopMangaDownloadQueue) v1Manga.DELETE("/download-queue", h.HandleClearAllChapterDownloadQueue) v1Manga.POST("/download-queue/reset-errored", h.HandleResetErroredChapterDownloadQueue) v1Manga.POST("/search", h.HandleMangaManualSearch) v1Manga.POST("/manual-mapping", h.HandleMangaManualMapping) v1Manga.POST("/get-mapping", h.HandleGetMangaMapping) v1Manga.POST("/remove-mapping", h.HandleRemoveMangaMapping) v1Manga.GET("/local-page/:path", h.HandleGetLocalMangaPage) // // File Cache // v1FileCache := v1.Group("/filecache") v1FileCache.GET("/total-size", h.HandleGetFileCacheTotalSize) v1FileCache.DELETE("/bucket", h.HandleRemoveFileCacheBucket) v1FileCache.GET("/mediastream/videofiles/total-size", h.HandleGetFileCacheMediastreamVideoFilesTotalSize) v1FileCache.DELETE("/mediastream/videofiles", h.HandleClearFileCacheMediastreamVideoFiles) // // Discord // v1Discord := v1.Group("/discord") v1Discord.POST("/presence/manga", h.HandleSetDiscordMangaActivity) v1Discord.POST("/presence/legacy-anime", h.HandleSetDiscordLegacyAnimeActivity) v1Discord.POST("/presence/anime", h.HandleSetDiscordAnimeActivityWithProgress) v1Discord.POST("/presence/anime-update", h.HandleUpdateDiscordAnimeActivityWithProgress) v1Discord.POST("/presence/cancel", h.HandleCancelDiscordActivity) // // Media Stream // v1.GET("/mediastream/settings", h.HandleGetMediastreamSettings) v1.PATCH("/mediastream/settings", h.HandleSaveMediastreamSettings) v1.POST("/mediastream/request", h.HandleRequestMediastreamMediaContainer) v1.POST("/mediastream/preload", h.HandlePreloadMediastreamMediaContainer) // Transcode v1.POST("/mediastream/shutdown-transcode", h.HandleMediastreamShutdownTranscodeStream) v1.GET("/mediastream/transcode/*", h.HandleMediastreamTranscode) v1.GET("/mediastream/subs/*", h.HandleMediastreamGetSubtitles) v1.GET("/mediastream/att/*", h.HandleMediastreamGetAttachments) v1.GET("/mediastream/direct", h.HandleMediastreamDirectPlay) v1.HEAD("/mediastream/direct", h.HandleMediastreamDirectPlay) v1.GET("/mediastream/file", h.HandleMediastreamFile) // // Direct Stream // v1.POST("/directstream/play/localfile", h.HandleDirectstreamPlayLocalFile) v1.GET("/directstream/stream", echo.WrapHandler(h.HandleDirectstreamGetStream())) v1.HEAD("/directstream/stream", echo.WrapHandler(h.HandleDirectstreamGetStream())) v1.GET("/directstream/att/*", h.HandleDirectstreamGetAttachments) // // Torrent stream // v1.GET("/torrentstream/settings", h.HandleGetTorrentstreamSettings) v1.PATCH("/torrentstream/settings", h.HandleSaveTorrentstreamSettings) v1.POST("/torrentstream/start", h.HandleTorrentstreamStartStream) v1.POST("/torrentstream/stop", h.HandleTorrentstreamStopStream) v1.POST("/torrentstream/drop", h.HandleTorrentstreamDropTorrent) v1.POST("/torrentstream/torrent-file-previews", h.HandleGetTorrentstreamTorrentFilePreviews) v1.POST("/torrentstream/batch-history", h.HandleGetTorrentstreamBatchHistory) v1.GET("/torrentstream/stream/*", h.HandleTorrentstreamServeStream) // // Extensions // v1Extensions := v1.Group("/extensions") v1Extensions.POST("/playground/run", h.HandleRunExtensionPlaygroundCode) v1Extensions.POST("/external/fetch", h.HandleFetchExternalExtensionData) v1Extensions.POST("/external/install", h.HandleInstallExternalExtension) v1Extensions.POST("/external/uninstall", h.HandleUninstallExternalExtension) v1Extensions.POST("/external/edit-payload", h.HandleUpdateExtensionCode) v1Extensions.POST("/external/reload", h.HandleReloadExternalExtensions) v1Extensions.POST("/external/reload", h.HandleReloadExternalExtension) v1Extensions.POST("/all", h.HandleGetAllExtensions) v1Extensions.GET("/updates", h.HandleGetExtensionUpdateData) v1Extensions.GET("/list", h.HandleListExtensionData) v1Extensions.GET("/payload/:id", h.HandleGetExtensionPayload) v1Extensions.GET("/list/development", h.HandleListDevelopmentModeExtensions) v1Extensions.GET("/list/manga-provider", h.HandleListMangaProviderExtensions) v1Extensions.GET("/list/onlinestream-provider", h.HandleListOnlinestreamProviderExtensions) v1Extensions.GET("/list/anime-torrent-provider", h.HandleListAnimeTorrentProviderExtensions) v1Extensions.GET("/user-config/:id", h.HandleGetExtensionUserConfig) v1Extensions.POST("/user-config", h.HandleSaveExtensionUserConfig) v1Extensions.GET("/marketplace", h.HandleGetMarketplaceExtensions) v1Extensions.GET("/plugin-settings", h.HandleGetPluginSettings) v1Extensions.POST("/plugin-settings/pinned-trays", h.HandleSetPluginSettingsPinnedTrays) v1Extensions.POST("/plugin-permissions/grant", h.HandleGrantPluginPermissions) // // Continuity // v1Continuity := v1.Group("/continuity") v1Continuity.PATCH("/item", h.HandleUpdateContinuityWatchHistoryItem) v1Continuity.GET("/item/:id", h.HandleGetContinuityWatchHistoryItem) v1Continuity.GET("/history", h.HandleGetContinuityWatchHistory) // // Sync // v1Local := v1.Group("/local") v1Local.GET("/track", h.HandleLocalGetTrackedMediaItems) v1Local.POST("/track", h.HandleLocalAddTrackedMedia) v1Local.DELETE("/track", h.HandleLocalRemoveTrackedMedia) v1Local.GET("/track/:id/:type", h.HandleLocalGetIsMediaTracked) v1Local.POST("/local", h.HandleLocalSyncData) v1Local.GET("/queue", h.HandleLocalGetSyncQueueState) v1Local.POST("/anilist", h.HandleLocalSyncAnilistData) v1Local.POST("/updated", h.HandleLocalSetHasLocalChanges) v1Local.GET("/updated", h.HandleLocalGetHasLocalChanges) v1Local.GET("/storage/size", h.HandleLocalGetLocalStorageSize) v1Local.POST("/sync-simulated-to-anilist", h.HandleLocalSyncSimulatedDataToAnilist) v1Local.POST("/offline", h.HandleSetOfflineMode) // // Debrid // v1.GET("/debrid/settings", h.HandleGetDebridSettings) v1.PATCH("/debrid/settings", h.HandleSaveDebridSettings) v1.POST("/debrid/torrents", h.HandleDebridAddTorrents) v1.POST("/debrid/torrents/download", h.HandleDebridDownloadTorrent) v1.POST("/debrid/torrents/cancel", h.HandleDebridCancelDownload) v1.DELETE("/debrid/torrent", h.HandleDebridDeleteTorrent) v1.GET("/debrid/torrents", h.HandleDebridGetTorrents) v1.POST("/debrid/torrents/info", h.HandleDebridGetTorrentInfo) v1.POST("/debrid/torrents/file-previews", h.HandleDebridGetTorrentFilePreviews) v1.POST("/debrid/stream/start", h.HandleDebridStartStream) v1.POST("/debrid/stream/cancel", h.HandleDebridCancelStream) // // Report // v1.POST("/report/issue", h.HandleSaveIssueReport) v1.GET("/report/issue/download", h.HandleDownloadIssueReport) // // Nakama // v1Nakama := v1.Group("/nakama") v1Nakama.GET("/ws", h.HandleNakamaWebSocket) v1Nakama.POST("/message", h.HandleSendNakamaMessage) v1Nakama.POST("/reconnect", h.HandleNakamaReconnectToHost) v1Nakama.POST("/cleanup", h.HandleNakamaRemoveStaleConnections) v1Nakama.GET("/host/anime/library", h.HandleGetNakamaAnimeLibrary) v1Nakama.GET("/host/anime/library/collection", h.HandleGetNakamaAnimeLibraryCollection) v1Nakama.GET("/host/anime/library/files/:id", h.HandleGetNakamaAnimeLibraryFiles) v1Nakama.GET("/host/anime/library/files", h.HandleGetNakamaAnimeAllLibraryFiles) v1Nakama.POST("/play", h.HandleNakamaPlayVideo) v1Nakama.GET("/host/torrentstream/stream", h.HandleNakamaHostTorrentstreamServeStream) v1Nakama.GET("/host/anime/library/stream", h.HandleNakamaHostAnimeLibraryServeStream) v1Nakama.GET("/host/debridstream/stream", h.HandleNakamaHostDebridstreamServeStream) v1Nakama.GET("/host/debridstream/url", h.HandleNakamaHostGetDebridstreamURL) v1Nakama.GET("/stream", h.HandleNakamaProxyStream) v1Nakama.POST("/watch-party/create", h.HandleNakamaCreateWatchParty) v1Nakama.POST("/watch-party/join", h.HandleNakamaJoinWatchParty) v1Nakama.POST("/watch-party/leave", h.HandleNakamaLeaveWatchParty) } func (h *Handler) JSON(c echo.Context, code int, i interface{}) error { return c.JSON(code, i) } func (h *Handler) RespondWithData(c echo.Context, data interface{}) error { return c.JSON(200, NewDataResponse(data)) } func (h *Handler) RespondWithError(c echo.Context, err error) error { return c.JSON(500, NewErrorResponse(err)) } func headMethodMiddleware(next echo.HandlerFunc) echo.HandlerFunc { return func(c echo.Context) error { // Skip directstream route if strings.Contains(c.Request().URL.Path, "/directstream/stream") { return next(c) } if c.Request().Method == http.MethodHead { // Set the method to GET temporarily to reuse the handler c.Request().Method = http.MethodGet defer func() { c.Request().Method = http.MethodHead }() // Restore method after // Call the next handler and then clear the response body if err := next(c); err != nil { if err.Error() == echo.ErrMethodNotAllowed.Error() { return c.NoContent(http.StatusOK) } return err } } return next(c) } }