Mostly finalize episode automations

This commit is contained in:
IRHM
2024-06-08 20:20:50 +01:00
parent faf6df5de5
commit 64223e9fa5
8 changed files with 187 additions and 23 deletions
+4 -1
View File
@@ -10,11 +10,13 @@ import (
type ActivityType string
// _AUTO activities are for when logic updates something for the user (automations basically).
var (
ADDED_WATCHED ActivityType = "ADDED_WATCHED"
REMOVED_WATCHED ActivityType = "REMOVED_WATCHED"
RATING_CHANGED ActivityType = "RATING_CHANGED"
STATUS_CHANGED ActivityType = "STATUS_CHANGED"
STATUS_CHANGED_AUTO ActivityType = "STATUS_CHANGED_AUTO"
THOUGHTS_CHANGED ActivityType = "THOUGHTS_CHANGED"
THOUGHTS_REMOVED ActivityType = "THOUGHTS_REMOVED"
IMPORTED_WATCHED ActivityType = "IMPORTED_WATCHED"
@@ -25,12 +27,13 @@ var (
IMPORTED_ADDED_WATCHED_JF ActivityType = "IMPORTED_ADDED_WATCHED_JF"
IMPORTED_ADDED_WATCHED_PLEX ActivityType = "IMPORTED_ADDED_WATCHED_PLEX"
SEASON_ADDED ActivityType = "SEASON_ADDED"
SEASON_ADDED_AUTO ActivityType = "SEASON_ADDED_AUTO" // When a season is added because of a different, but related user action (eg: setting an episode to watched)
SEASON_ADDED_AUTO ActivityType = "SEASON_ADDED_AUTO"
SEASON_ADDED_JF ActivityType = "SEASON_ADDED_JF"
SEASON_ADDED_PLEX ActivityType = "SEASON_ADDED_PLEX"
SEASON_REMOVED ActivityType = "SEASON_REMOVED"
SEASON_RATING_CHANGED ActivityType = "SEASON_RATING_CHANGED"
SEASON_STATUS_CHANGED ActivityType = "SEASON_STATUS_CHANGED"
SEASON_STATUS_CHANGED_AUTO ActivityType = "SEASON_STATUS_CHANGED_AUTO"
EPISODE_ADDED ActivityType = "EPISODE_ADDED"
EPISODE_ADDED_JF ActivityType = "EPISODE_ADDED_JF"
EPISODE_ADDED_PLEX ActivityType = "EPISODE_ADDED_PLEX"
+9 -6
View File
@@ -39,6 +39,8 @@ type WatchedEpisodeAddRequest struct {
type WatchedEpisodeAddResponse struct {
WatchedEpisodes []WatchedEpisode `json:"watchedEpisodes"`
AddedActivity Activity `json:"addedActivity"`
// Response from hook
EpisodeStatusChangedHookResponse EpisodeStatusChangedHookResponse `json:"episodeStatusChangedHookResponse,omitempty"`
}
// Add/edit a watched episode.
@@ -115,14 +117,15 @@ func addWatchedEpisodes(db *gorm.DB, userId uint, ar WatchedEpisodeAddRequest) (
}
addedActivity, _ = addActivity(db, userId, act)
}
if ar.Status != "" {
slog.Debug("addWatchedEpisodes: Episode status was changed, calling hook.")
hookEpisodeStatusChanged(db, userId, ar.WatchedID, ar.SeasonNumber, ar.EpisodeNumber, ar.Status)
}
return WatchedEpisodeAddResponse{
episodeAddResp := WatchedEpisodeAddResponse{
WatchedEpisodes: w.WatchedEpisodes,
AddedActivity: addedActivity,
}, nil
}
if ar.Status != "" {
slog.Debug("addWatchedEpisodes: Episode status was changed, calling hook.")
episodeAddResp.EpisodeStatusChangedHookResponse = hookEpisodeStatusChanged(db, userId, ar.WatchedID, ar.SeasonNumber, ar.EpisodeNumber, ar.Status)
}
return episodeAddResp, nil
}
// Remove a watched episode
+64 -13
View File
@@ -1,28 +1,48 @@
package main
import (
"encoding/json"
"fmt"
"log/slog"
"strconv"
"gorm.io/gorm"
)
// TODO add our own activity when we update a season/show status!
// TODO might need to use pointers
type EpisodeStatusChangedHookResponse struct {
// The watched shows status if we modified it.
NewShowStatus WatchedStatus `json:"newShowStatus,omitempty"`
// The full watched season (if created or modified).
WatchedSeason WatchedSeason `json:"watchedSeason,omitempty"`
// All activies we have added.
AddedActivities []Activity `json:"addedActivities,omitempty"`
// All errors (fatal and non-fatal) that were encountered.
Errors []string `json:"errors,omitempty"`
}
// Called after an episode watched status has been set.
func hookEpisodeStatusChanged(db *gorm.DB, userId uint, watchedId uint, seasonNum int, episodeNum int, newEpisodeStatus WatchedStatus) {
func hookEpisodeStatusChanged(db *gorm.DB, userId uint, watchedId uint, seasonNum int, episodeNum int, newEpisodeStatus WatchedStatus) EpisodeStatusChangedHookResponse {
// 1. Only continue if the episode was not marked dropped.
if newEpisodeStatus == DROPPED {
slog.Error("hookEpisodeStatusChanged: newEpisodeStatus is DROPPED, not continuing.")
return
return EpisodeStatusChangedHookResponse{}
}
hookResponse := EpisodeStatusChangedHookResponse{}
addHookActivity := func(aType ActivityType, data string) {
addedActivity, _ := addActivity(db, userId, ActivityAddRequest{WatchedID: watchedId, Type: aType, Data: (data)})
hookResponse.AddedActivities = append(hookResponse.AddedActivities, addedActivity)
}
// 2. If the season (this episode is in) has no status or is planned, set season to watching.
watchedSeason, err := getWatchedSeason(db, userId, watchedId, seasonNum)
if err != nil {
slog.Error("hookEpisodeStatusChanged: Cannot continue, failed to get watchedSeason!", "error", err)
return
return EpisodeStatusChangedHookResponse{Errors: []string{("failed to query db for watched season")}}
}
// If season not found, create it.
if watchedSeason == nil {
@@ -31,19 +51,35 @@ func hookEpisodeStatusChanged(db *gorm.DB, userId uint, watchedId uint, seasonNu
if newEpisodeStatus == FINISHED {
seasonStatus = WATCHING
}
_, err := addWatchedSeason(db, userId, WatchedSeasonAddRequest{
addActivity: SEASON_ADDED_AUTO,
WatchedID: watchedId,
SeasonNumber: seasonNum,
Status: seasonStatus,
resp, err := addWatchedSeason(db, userId, WatchedSeasonAddRequest{
addActivity: SEASON_ADDED_AUTO,
addActivityData: map[string]interface{}{"reason": fmt.Sprintf("Episode %d was set to %s while the season had no status.", episodeNum, newEpisodeStatus)},
WatchedID: watchedId,
SeasonNumber: seasonNum,
Status: seasonStatus,
})
if err != nil {
slog.Error("hookEpisodeStatusChanged: Failed to add watched season!", "error", err)
hookResponse.Errors = append(hookResponse.Errors, "failed to add watched season")
} else {
// addWatchedSeason returns all watched seasons, get the one just added. (may be best to retrofit addWatchedSeason later to return id of season/row created)
justAddedWatchedSeason, err := getWatchedSeason(db, userId, watchedId, seasonNum)
if err != nil {
hookResponse.Errors = append(hookResponse.Errors, "failed to get newly added watched season for response")
} else {
hookResponse.WatchedSeason = *justAddedWatchedSeason
}
hookResponse.AddedActivities = append(hookResponse.AddedActivities, resp.AddedActivity)
}
} else if watchedSeason.Status == "" || watchedSeason.Status == PLANNED {
watchedSeason.Status = WATCHING
if res := db.Save(watchedSeason); res.Error != nil {
slog.Error("hookEpisodeStatusChanged: Failed to update season status!", "error", res.Error)
hookResponse.Errors = append(hookResponse.Errors, "failed to update season status")
} else {
hookResponse.WatchedSeason = *watchedSeason
json, _ := json.Marshal(map[string]interface{}{"season": seasonNum, "status": watchedSeason.Status, "reason": fmt.Sprintf("Episode %d was set to %s while the season had no status.", episodeNum, newEpisodeStatus)})
addHookActivity(SEASON_STATUS_CHANGED_AUTO, string(json))
}
}
@@ -51,13 +87,18 @@ func hookEpisodeStatusChanged(db *gorm.DB, userId uint, watchedId uint, seasonNu
watchedShow, err := getWatchedItemById(db, userId, watchedId)
if err != nil {
slog.Error("hookEpisodeStatusChanged: Failed to get watched show, cant continue to update show status.", "error", err)
return
hookResponse.Errors = append(hookResponse.Errors, "failed to get watched item for show")
return hookResponse
} else {
// Show status shouldn't be empty, but watevs, handle it just incase
if watchedShow.Status == "" || watchedShow.Status == PLANNED {
watchedShow.Status = WATCHING
if res := db.Save(watchedShow); res.Error != nil {
slog.Error("hookEpisodeStatusChanged: Failed to update show status!", "error", res.Error)
} else {
hookResponse.NewShowStatus = watchedShow.Status
json, _ := json.Marshal(map[string]interface{}{"status": watchedShow.Status, "reason": fmt.Sprintf("S%dE%d was set to %s.", seasonNum, episodeNum, newEpisodeStatus)})
addHookActivity(STATUS_CHANGED_AUTO, string(json))
}
}
}
@@ -68,20 +109,30 @@ func hookEpisodeStatusChanged(db *gorm.DB, userId uint, watchedId uint, seasonNu
seasonDetails, err := seasonDetails(tmdbIdStr, seasonNumStr)
if err != nil {
slog.Error("hookEpisodeStatusChanged: Failed to get season details!", "error", err)
return
hookResponse.Errors = append(hookResponse.Errors, "failed to get season details for show")
return hookResponse
}
allEpisodesCount := len(seasonDetails.Episodes)
finishedEpisodesCount, err := getNumberOfWatchedEpisodesInSeason(db, userId, watchedId, seasonNum, []WatchedStatus{FINISHED, DROPPED})
if err != nil {
slog.Error("hookEpisodeStatusChanged: Failed to get number of watched episodes in this season!", "error", err)
return
hookResponse.Errors = append(hookResponse.Errors, "failed to get number of watched episodes in this season")
return hookResponse
}
slog.Debug("hookEpisodeStatusChanged: Got episode counts.", "allEpisodesCount", allEpisodesCount, "finishedEpisodesCount", finishedEpisodesCount)
if finishedEpisodesCount >= int64(allEpisodesCount) {
slog.Debug("hookEpisodeStatusChanged: All episodes have been completed (finished or dropped). Marking season finished.")
if res := db.Model(&WatchedSeason{}).Where("watched_id = ? AND season_number = ? AND user_id = ?", watchedId, seasonNum, userId).Update("status", FINISHED); res.Error != nil {
newStatus := FINISHED
if res := db.Model(&WatchedSeason{}).Where("watched_id = ? AND season_number = ? AND user_id = ?", watchedId, seasonNum, userId).Update("status", newStatus); res.Error != nil {
slog.Error("hookEpisodeStatusChanged: Failed to update season status to finished:", "error", res.Error.Error())
return
hookResponse.Errors = append(hookResponse.Errors, "failed to update season status to finished")
return hookResponse
} else {
hookResponse.WatchedSeason.Status = newStatus
json, _ := json.Marshal(map[string]interface{}{"season": seasonNum, "status": newStatus, "reason": fmt.Sprintf("The season was deemed completed when episode %d was set to %s.", episodeNum, newEpisodeStatus)})
addHookActivity(SEASON_STATUS_CHANGED_AUTO, string(json))
}
}
return hookResponse
}
+12 -1
View File
@@ -28,6 +28,9 @@ type WatchedSeasonAddRequest struct {
Rating int8 `json:"rating"`
addActivity ActivityType `json:"-"`
addActivityDate time.Time `json:"-"`
// Data to add to activity if the season is created.
// Combined with data we already add.
addActivityData map[string]interface{} `json:"-"`
}
type WatchedSeasonAddResponse struct {
@@ -98,7 +101,15 @@ func addWatchedSeason(db *gorm.DB, userId uint, ar WatchedSeasonAddRequest) (Wat
}
}
} else {
json, _ := json.Marshal(map[string]interface{}{"season": ar.SeasonNumber, "status": ar.Status, "rating": ar.Rating})
actData := map[string]interface{}{"season": ar.SeasonNumber, "status": ar.Status, "rating": ar.Rating}
if len(ar.addActivityData) > 0 {
for k, v := range ar.addActivityData {
if _, ok := ar.addActivityData[k]; ok {
actData[k] = v
}
}
}
json, _ := json.Marshal(actData)
act := ActivityAddRequest{WatchedID: w.ID, Type: SEASON_ADDED, Data: string(json)}
if ar.addActivity != "" {
act.Type = ar.addActivity
+41 -1
View File
@@ -2,6 +2,8 @@
import type { Activity } from "@/types";
import { getOrdinalSuffix, months, seasonAndEpToReadable } from "./util/helpers";
import ActivityEditor from "./ActivityEditor.svelte";
import Icon from "./Icon.svelte";
import tooltip from "./actions/tooltip";
export let activity: Activity[] | undefined;
export let wListId: number;
@@ -36,6 +38,12 @@
return `Status Changed to ${toFullTitleCase(a.data)}`;
}
return "Status Changed";
case "STATUS_CHANGED_AUTO":
if (a.data) {
const data = JSON.parse(a.data);
return `Status Changed to ${toFullTitleCase(data.status)}`;
}
return "Status Changed";
case "THOUGHTS_CHANGED":
return "Thoughts Changed";
case "THOUGHTS_REMOVED":
@@ -60,6 +68,7 @@
case "IMPORTED_ADDED_WATCHED_PLEX":
return "Imported Watch Date";
case "SEASON_ADDED":
case "SEASON_ADDED_AUTO":
if (a.data) {
const data = JSON.parse(a.data);
return `Season ${data.season} Added as ${toFullTitleCase(data.status)}`;
@@ -79,6 +88,7 @@
}
return "Season Rating Changed";
case "SEASON_STATUS_CHANGED":
case "SEASON_STATUS_CHANGED_AUTO":
if (a.data) {
const data = JSON.parse(a.data);
return `Changed Season ${data.season} Status to ${toFullTitleCase(data.status)}`;
@@ -172,6 +182,16 @@
isActivityEditorVisible = true;
return;
}
function getActivityDataParsed(a: Activity) {
try {
if (a.data) {
return JSON.parse(a.data);
}
} catch (err) {
console.error("getActivityDataParsed: Failed!", err);
}
}
</script>
{#if isActivityEditorVisible}
@@ -197,6 +217,21 @@
<span title={d.toDateString()}>{toDayTime(d)}</span>
<span>{getMsg(a)}</span>
</button>
{#if a.type?.endsWith("_AUTO")}
{@const data = getActivityDataParsed(a)}
<i
use:tooltip={{
text:
data && data.reason
? `Automated because ${data.reason}`
: "Completed by an automation.",
pos: "bot"
}}
style="width: 20px; height: 20px;"
>
<Icon i="sparkles" wh={20} />
</i>
{/if}
</li>
{/each}
{/each}
@@ -235,6 +270,11 @@
}
li {
display: flex;
flex-flow: row;
gap: 8px;
align-items: center;
button {
all: unset;
display: flex;
@@ -254,7 +294,7 @@
min-width: max-content;
}
&:last-child {
&:last-of-type {
background-color: $accent-color;
color: $text-color;
border-radius: 8px;
+6
View File
@@ -332,6 +332,12 @@
d="M680-840v80h-40v327L313-760l-33-33v-47h400ZM480-40l-40-40v-240H240v-80l80-80v-46L56-792l56-56 736 736-58 56-264-264h-6v240l-40 40Z"
/>
</svg>
{:else if i === "sparkles"}
<svg xmlns="http://www.w3.org/2000/svg" width={wh} height={wh} viewBox="0 0 512 512">
<path
d="M208 512a24.84 24.84 0 01-23.34-16l-39.84-103.6a16.06 16.06 0 00-9.19-9.19L32 343.34a25 25 0 010-46.68l103.6-39.84a16.06 16.06 0 009.19-9.19L184.66 144a25 25 0 0146.68 0l39.84 103.6a16.06 16.06 0 009.19 9.19l103 39.63a25.49 25.49 0 0116.63 24.1 24.82 24.82 0 01-16 22.82l-103.6 39.84a16.06 16.06 0 00-9.19 9.19L231.34 496A24.84 24.84 0 01208 512zm66.85-254.84zM88 176a14.67 14.67 0 01-13.69-9.4l-16.86-43.84a7.28 7.28 0 00-4.21-4.21L9.4 101.69a14.67 14.67 0 010-27.38l43.84-16.86a7.31 7.31 0 004.21-4.21L74.16 9.79A15 15 0 0186.23.11a14.67 14.67 0 0115.46 9.29l16.86 43.84a7.31 7.31 0 004.21 4.21l43.84 16.86a14.67 14.67 0 010 27.38l-43.84 16.86a7.28 7.28 0 00-4.21 4.21l-16.86 43.84A14.67 14.67 0 0188 176zM400 256a16 16 0 01-14.93-10.26l-22.84-59.37a8 8 0 00-4.6-4.6l-59.37-22.84a16 16 0 010-29.86l59.37-22.84a8 8 0 004.6-4.6l22.67-58.95a16.45 16.45 0 0113.17-10.57 16 16 0 0116.86 10.15l22.84 59.37a8 8 0 004.6 4.6l59.37 22.84a16 16 0 010 29.86l-59.37 22.84a8 8 0 00-4.6 4.6l-22.84 59.37A16 16 0 01400 256z"
/>
</svg>
{/if}
<style lang="scss">
+41
View File
@@ -53,6 +53,47 @@
} else {
wEntry.activity = [r.data.addedActivity];
}
try {
const epHookResp = r?.data?.episodeStatusChangedHookResponse;
if (epHookResp) {
if (epHookResp.errors && epHookResp.errors.length > 0) {
console.error(
"episodeStatusChangedHookResponse contained errors! All possible automations may not have been completed.",
epHookResp.errors
);
notify({
type: "error",
text: "Some automations have failed, check console for more info."
});
}
if (epHookResp.addedActivities && epHookResp.addedActivities.length > 0) {
wEntry.activity.push(...epHookResp.addedActivities);
}
if (epHookResp.watchedSeason) {
if (!wEntry.watchedSeasons) {
wEntry.watchedSeasons = [epHookResp.watchedSeason];
} else {
const watchedSeasonIdx = wEntry.watchedSeasons.findIndex(
(s) => s.id === epHookResp.watchedSeason?.id
);
if (watchedSeasonIdx === -1) {
wEntry.watchedSeasons.push(epHookResp.watchedSeason);
} else {
wEntry.watchedSeasons[watchedSeasonIdx] = epHookResp.watchedSeason;
}
}
}
if (epHookResp.newShowStatus) {
wEntry.status = epHookResp.newShowStatus;
}
}
} catch (err) {
console.error("Failed to process episodeStatusChangedHookResponse", err);
notify({
type: "error",
text: "Failed to process automation response, check console for more info."
});
}
watchedList.update((w) => w);
notify({ id: nid, text: `Saved!`, type: "success" });
}
+10 -1
View File
@@ -39,7 +39,8 @@ export type Icon =
| "film"
| "tv"
| "pin"
| "unpin";
| "unpin"
| "sparkles";
export type Theme = "light" | "dark";
@@ -145,6 +146,14 @@ export interface WatchedSeasonAddResponse {
export interface WatchedEpisodeAddResponse {
watchedEpisodes: WatchedEpisode[];
addedActivity: Activity;
episodeStatusChangedHookResponse?: EpisodeStatusChangedHookResponse;
}
export interface EpisodeStatusChangedHookResponse {
newShowStatus?: WatchedStatus;
watchedSeason?: WatchedSeason;
addedActivities?: Activity[];
errors?: string[];
}
export interface Profile {