Files
silo-server/internal/watchsync/providers/simkl/provider.go
203a18ae83 feat(observability): OpenTelemetry logs+traces with secret redaction and slog standardization (#290)
* feat(observability): OpenTelemetry logs+traces with secret redaction

Part of #265. Adds opt-in OpenTelemetry (logs + traces) alongside the existing
stderr + opslog pipeline, plus secret redaction on all sinks. Default-off: with
no OTEL_* / SILO_OTEL_ENABLED config, behavior is unchanged.

Bootstrap (internal/telemetry):
- Setup() builds one shared resource, a TracerProvider (parent-based trace-id
  ratio sampler), a LoggerProvider, and the W3C TraceContext+Baggage propagator
  from env. It installs NO MeterProvider — metrics stay on Prometheus, and the
  built-in no-op global MeterProvider keeps the trace instrumentation libs from
  double-emitting. Shutdown is deferred with a flush timeout.
- Logs are bridged via otelslog fan-out (slog.MultiHandler), level-gated by the
  shared LevelVar and best-effort so a failing collector can't break the console
  or DB branches. stderr + opslog stay untouched.

Secret redaction (internal/logredact):
- A slog.Handler masks secret-keyed attributes (password, token, api_key,
  authorization, cookie, ...) — including .With-bound attrs, nested groups,
  secret-keyed group subtrees, and values behind a LogValuer — on the console
  and OTLP sinks, with a no-op fast path when a record has no secret keys.
  opslog.shouldRedact delegates to logredact.SecretKey so all sinks share one
  marker list.

Rotation is infra-managed (no custom file sink): container runtime for stderr,
collector/backend for OTLP, opslog partition-pruning for the DB. Documented in
docs/architecture/observability.md.

Verification: go build ./..., go vet, gofmt -l — clean; go test
./internal/telemetry/ ./internal/logredact/ -race pass.

AI-use disclosure: implemented with AI assistance (Claude Code), including
adversarial reviews that hardened the bootstrap and fixed two redaction leak
paths; reviewed by the author.

* refactor(observability): slog context+component sweep, sloglint gate (phase 3)

Part of #265. Builds on the OTel bootstrap + redaction commit.

Standardizes every log call site onto the context-carrying slog variants so
records correlate with the active OpenTelemetry trace, and locks the standard
in with a machine gate so future code (human- or AI-authored) can't drift back.

- Call-site sweep: converted the remaining slog.<Level>(...) calls to the
  slog.<Level>Context(ctx, ...) form wherever a context.Context is in scope
  (background/init calls with no ctx are left as-is), across 183 files. Applied
  via a type-aware AST codemod. Log levels and message strings are preserved
  verbatim; a component attr (canonical per-package name) is added to direct
  package-level slog calls. Bound-logger calls keep their existing .With
  bindings. The main.go and telemetry package conversions rode with their file
  in the previous commit to keep each file within a single commit.
- Enforcement (.golangci.yml): enable sloglint with context=scope, static-msg,
  key-naming-case=snake, no-mixed-args. After the sweep all four report zero
  violations repo-wide (tests included), so make lint / CI now blocks any
  regression to the non-context form. The gate ships with the sweep because it
  cannot be green until the legacy sites are converted.

Metrics remain on Prometheus; no behavior change to /metrics or Grafana.

Verification: go build ./..., go vet ./..., gofmt -l — clean; sloglint (all 4
rules) 0 violations repo-wide; log levels verified unchanged.

AI-use disclosure: implemented with AI assistance (Claude Code), including the
codemod; reviewed by the author.

* fix(observability): honor per-signal OTLP protocol and secret WithGroup names

Two Codex review findings on PR #290:

- telemetry: OTEL_EXPORTER_OTLP_{TRACES,LOGS}_PROTOCOL now override the
  generic OTEL_EXPORTER_OTLP_PROTOCOL per signal, so mixed collector
  setups (e.g. HTTP logs + gRPC traces) build the right exporter.
- logredact: entering a group whose name is secret-bearing (e.g.
  WithGroup("authorization")) now masks every leaf in that subtree,
  matching how slog.Group("authorization", ...) is masked as a whole.

* fix(observability): address review feedback on telemetry bootstrap

- Telemetry setup failure no longer kills boot: Setup returns usable
  no-op providers alongside the error and main logs and continues with
  telemetry disabled, honoring the best-effort contract.
- Honor OTEL_TRACES_SAMPLER (always_on/off, traceidratio, parentbased_*
  variants); unsupported values fall back to parentbased_traceidratio.
- Attach node identity as semconv service.instance.id instead of the
  non-semconv node.name.
- Rename opslog retention-scope log attrs to target_component/target_level
  so they no longer collide with the canonical component routing key, and
  tag those lines with component=opslog.
- Fix stale levelGated comment casing; use WarnContext in the telemetry
  shutdown defer; document the LogValuer double-resolve on the redaction
  slow path.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

---------

Co-authored-by: Quick <31828688+Quick104@users.noreply.github.com>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-07-09 08:53:52 -04:00

1246 lines
39 KiB
Go

package simkl
import (
"bytes"
"context"
"encoding/json"
"errors"
"fmt"
"io"
"log/slog"
"net/http"
"net/url"
"strconv"
"strings"
"time"
"github.com/Silo-Server/silo-server/internal/historyimport"
"github.com/Silo-Server/silo-server/internal/userstore"
"github.com/Silo-Server/silo-server/internal/watchsync"
)
const defaultBaseURL = "https://api.simkl.com"
const (
simklCursorInboundMoviesCompleted = "simkl.inbound.movies.completed"
simklCursorInboundShowsWatching = "simkl.inbound.shows.watching"
simklCursorInboundShowsCompleted = "simkl.inbound.shows.completed"
simklCursorInboundAnimeWatching = "simkl.inbound.anime.watching"
simklCursorInboundAnimeCompleted = "simkl.inbound.anime.completed"
simklCursorProgressMovies = "simkl.progress.movies"
simklCursorProgressShows = "simkl.progress.shows"
simklCursorProgressAnime = "simkl.progress.anime"
simklCursorRemovedMovies = "simkl.inbound.movies.removed_from_list"
simklCursorRemovedShows = "simkl.inbound.shows.removed_from_list"
simklCursorRemovedAnime = "simkl.inbound.anime.removed_from_list"
)
type Provider struct {
client *http.Client
baseURL string
}
func NewProvider(client *http.Client, baseURL string) *Provider {
if client == nil {
client = &http.Client{Timeout: 20 * time.Second}
}
if strings.TrimSpace(baseURL) == "" {
baseURL = defaultBaseURL
}
return &Provider{client: client, baseURL: strings.TrimRight(baseURL, "/")}
}
func (p *Provider) Key() string {
return "simkl"
}
func (p *Provider) DisplayName() string {
return "Simkl"
}
func (p *Provider) Capabilities() watchsync.Capabilities {
return watchsync.Capabilities{
ImportWatched: true,
ImportProgress: true,
ExportWatched: true,
ExportUnwatched: true,
ImportWatchlist: true,
ExportWatchlist: true,
RemoveWatchlist: true,
ScrobblePlayback: true,
}
}
func (p *Provider) HistorySource() userstore.WatchHistorySource {
return userstore.WatchHistorySourceSimkl
}
func (p *Provider) ScrobbleOrderingKey(conn watchsync.Connection, _ watchsync.ScrobbleEvent) string {
return "simkl:" + conn.ID
}
func (p *Provider) StartDeviceAuth(ctx context.Context, cfg watchsync.ServerConfig) (watchsync.DeviceAuthSession, error) {
if !cfg.Configured() {
return watchsync.DeviceAuthSession{}, errors.New("simkl server config is not configured")
}
var response pinCodeResponse
if err := p.do(ctx, http.MethodGet, "/oauth/pin?client_id="+url.QueryEscape(cfg.ClientID), cfg, "", nil, &response); err != nil {
return watchsync.DeviceAuthSession{}, err
}
if response.Result != "OK" || response.UserCode == "" || response.VerificationURL == "" ||
response.ExpiresIn <= 0 || response.Interval <= 0 {
return watchsync.DeviceAuthSession{}, errors.New("simkl pin auth response is missing required fields")
}
deviceCode := response.DeviceCode
if deviceCode == "" {
deviceCode = response.UserCode
}
return watchsync.DeviceAuthSession{
Provider: p.Key(),
DeviceCode: deviceCode,
UserCode: response.UserCode,
VerificationURL: response.VerificationURL,
IntervalSeconds: response.Interval,
ExpiresAt: time.Now().UTC().Add(time.Duration(response.ExpiresIn) * time.Second),
}, nil
}
func (p *Provider) PollDeviceAuth(ctx context.Context, cfg watchsync.ServerConfig, session watchsync.DeviceAuthSession) (watchsync.TokenSet, error) {
if !cfg.Configured() {
return watchsync.TokenSet{}, errors.New("simkl server config is not configured")
}
userCode := session.UserCode
if userCode == "" {
userCode = session.DeviceCode
}
var response pinStatusResponse
path := "/oauth/pin/" + url.PathEscape(userCode) + "?client_id=" + url.QueryEscape(cfg.ClientID)
if err := p.do(ctx, http.MethodGet, path, cfg, "", nil, &response); err != nil {
return watchsync.TokenSet{}, err
}
if response.Result != "OK" || strings.TrimSpace(response.AccessToken) == "" {
message := strings.TrimSpace(response.Message)
if message == "" {
message = "authorization pending"
}
return watchsync.TokenSet{}, fmt.Errorf("simkl pin authorization pending: %s", message)
}
return watchsync.TokenSet{AccessToken: strings.TrimSpace(response.AccessToken)}, nil
}
func (p *Provider) RefreshToken(_ context.Context, _ watchsync.ServerConfig, conn watchsync.Connection) (watchsync.TokenSet, error) {
if conn.TokenExpiresAt == nil {
return watchsync.TokenSet{AccessToken: conn.AccessToken}, nil
}
return watchsync.TokenSet{}, errors.New("simkl access tokens do not support refresh")
}
func (p *Provider) LookupAccount(ctx context.Context, cfg watchsync.ServerConfig, conn watchsync.Connection) (watchsync.ProviderAccount, error) {
var response struct {
User struct {
Name string `json:"name"`
} `json:"user"`
Account struct {
ID int `json:"id"`
} `json:"account"`
}
if err := p.do(ctx, http.MethodPost, "/users/settings", cfg, conn.AccessToken, nil, &response); err != nil {
return watchsync.ProviderAccount{}, err
}
id := strconv.Itoa(response.Account.ID)
if response.Account.ID == 0 {
id = response.User.Name
}
return watchsync.ProviderAccount{ID: id, Username: response.User.Name}, nil
}
func (p *Provider) FetchWatched(ctx context.Context, cfg watchsync.ServerConfig, conn watchsync.Connection) ([]watchsync.RemoteWatch, error) {
batch, err := p.FetchWatchedBatch(ctx, cfg, conn)
if err != nil {
return nil, err
}
return batch.Rows, nil
}
func (p *Provider) FetchWatchedBatch(ctx context.Context, cfg watchsync.ServerConfig, conn watchsync.Connection) (watchsync.WatchedImportBatch, error) {
return p.fetchWatchedBatch(ctx, cfg, conn, true)
}
func (p *Provider) fetchWatchedBatch(ctx context.Context, cfg watchsync.ServerConfig, conn watchsync.Connection, useCursors bool) (watchsync.WatchedImportBatch, error) {
activities, err := p.fetchActivities(ctx, cfg, conn)
if err != nil {
return watchsync.WatchedImportBatch{}, err
}
batch := watchsync.WatchedImportBatch{
UpdatedCursors: make(map[string]string),
}
p.addRemovedListWarning(conn, activities.Movies.RemovedFromList, simklCursorRemovedMovies, &batch)
p.addRemovedListWarning(conn, activities.TVShows.RemovedFromList, simklCursorRemovedShows, &batch)
p.addRemovedListWarning(conn, activities.Anime.RemovedFromList, simklCursorRemovedAnime, &batch)
for _, bucket := range watchedBuckets(activities) {
previous := conn.SyncCursors[bucket.cursorKey]
if useCursors && shouldSkipSimklBucket(previous, bucket.activity) {
continue
}
path := bucket.path
if useCursors && previous != "" {
path = appendDateFrom(path, previous)
}
var payload simklAllItemsResponse
if err := p.do(ctx, http.MethodGet, path, cfg, conn.AccessToken, nil, &payload); err != nil {
return watchsync.WatchedImportBatch{}, err
}
rows, warnings := watchedRowsFromAllItems(payload, bucket.allowShowTimestampFallback)
batch.Rows = append(batch.Rows, rows...)
batch.Warnings = append(batch.Warnings, warnings...)
if useCursors && bucket.activity != "" {
batch.UpdatedCursors[bucket.cursorKey] = bucket.activity
}
}
return batch, nil
}
func (p *Provider) FetchProgress(ctx context.Context, cfg watchsync.ServerConfig, conn watchsync.Connection) ([]watchsync.RemoteProgress, error) {
batch, err := p.FetchProgressBatch(ctx, cfg, conn)
if err != nil {
return nil, err
}
return batch.Rows, nil
}
func (p *Provider) FetchProgressBatch(ctx context.Context, cfg watchsync.ServerConfig, conn watchsync.Connection) (watchsync.ProgressImportBatch, error) {
activities, err := p.fetchActivities(ctx, cfg, conn)
if err != nil {
return watchsync.ProgressImportBatch{}, err
}
batch := watchsync.ProgressImportBatch{
UpdatedCursors: make(map[string]string),
}
moviePrevious := conn.SyncCursors[simklCursorProgressMovies]
if !shouldSkipSimklBucket(moviePrevious, activities.Movies.Playback) {
payload, err := p.fetchPlayback(ctx, cfg, conn, "/sync/playback/movies", moviePrevious)
if err != nil {
return watchsync.ProgressImportBatch{}, err
}
rows, warnings, _ := progressRowsFromPlayback(payload, p.Key())
batch.Rows = append(batch.Rows, rows...)
batch.Warnings = append(batch.Warnings, warnings...)
if activities.Movies.Playback != "" {
batch.UpdatedCursors[simklCursorProgressMovies] = activities.Movies.Playback
}
}
showsPrevious := conn.SyncCursors[simklCursorProgressShows]
animePrevious := conn.SyncCursors[simklCursorProgressAnime]
showsChanged := !shouldSkipSimklBucket(showsPrevious, activities.TVShows.Playback)
animeChanged := !shouldSkipSimklBucket(animePrevious, activities.Anime.Playback)
if showsChanged || animeChanged {
dateFrom := ""
if !(showsChanged && showsPrevious == "") && !(animeChanged && animePrevious == "") {
dateFrom = oldestCursor(showsPrevious, animePrevious)
}
payload, err := p.fetchPlayback(ctx, cfg, conn, "/sync/playback/episodes", dateFrom)
if err != nil {
return watchsync.ProgressImportBatch{}, err
}
rows, warnings, hasAnimeRows := progressRowsFromPlayback(payload, p.Key())
batch.Rows = append(batch.Rows, rows...)
batch.Warnings = append(batch.Warnings, warnings...)
if showsChanged && activities.TVShows.Playback != "" {
batch.UpdatedCursors[simklCursorProgressShows] = activities.TVShows.Playback
}
if animeChanged && activities.Anime.Playback != "" && hasAnimeRows {
batch.UpdatedCursors[simklCursorProgressAnime] = activities.Anime.Playback
}
}
return batch, nil
}
func (p *Provider) fetchPlayback(ctx context.Context, cfg watchsync.ServerConfig, conn watchsync.Connection, path string, dateFrom string) ([]simklPlayback, error) {
if dateFrom != "" {
path = appendDateFrom(path, dateFrom)
}
var payload []simklPlayback
if err := p.do(ctx, http.MethodGet, path, cfg, conn.AccessToken, nil, &payload); err != nil {
return nil, err
}
return payload, nil
}
func (p *Provider) FetchHistory(ctx context.Context, cfg watchsync.ServerConfig, conn watchsync.Connection) ([]watchsync.RemotePlay, error) {
slog.WarnContext(ctx, "simkl full watched fetch used for export dedupe", "component", "watchsync", "provider", p.Key())
batch, err := p.fetchWatchedBatch(ctx, cfg, conn, false)
if err != nil {
return nil, err
}
rows := make([]watchsync.RemotePlay, 0, len(batch.Rows))
for _, row := range batch.Rows {
if row.LastWatchedAt == nil {
continue
}
rows = append(rows, watchsync.RemotePlay{
Provider: row.Provider,
ProviderItemKey: row.ProviderItemKey,
Kind: row.Kind,
Title: row.Title,
Year: row.Year,
IMDbID: row.IMDbID,
TMDBID: row.TMDBID,
TVDBID: row.TVDBID,
SeriesTitle: row.SeriesTitle,
SeriesYear: row.SeriesYear,
SeriesIMDbID: row.SeriesIMDbID,
SeriesTMDBID: row.SeriesTMDBID,
SeriesTVDBID: row.SeriesTVDBID,
SeasonNumber: row.SeasonNumber,
EpisodeNumber: row.EpisodeNumber,
WatchedAt: *row.LastWatchedAt,
})
}
return rows, nil
}
func (p *Provider) ExportHistory(ctx context.Context, cfg watchsync.ServerConfig, conn watchsync.Connection, plays []watchsync.LocalPlay) (watchsync.ExportResult, error) {
return p.sendHistory(ctx, cfg, conn, plays, "/sync/history", true)
}
func (p *Provider) RemoveHistory(ctx context.Context, cfg watchsync.ServerConfig, conn watchsync.Connection, plays []watchsync.LocalPlay) (watchsync.ExportResult, error) {
return p.sendHistory(ctx, cfg, conn, plays, "/sync/history/remove", false)
}
func (p *Provider) Start(ctx context.Context, cfg watchsync.ServerConfig, conn watchsync.Connection, event watchsync.ScrobbleEvent) error {
return p.scrobble(ctx, "/scrobble/start", cfg, conn, event)
}
func (p *Provider) Pause(ctx context.Context, cfg watchsync.ServerConfig, conn watchsync.Connection, event watchsync.ScrobbleEvent) error {
return p.scrobble(ctx, "/scrobble/pause", cfg, conn, event)
}
func (p *Provider) Stop(ctx context.Context, cfg watchsync.ServerConfig, conn watchsync.Connection, event watchsync.ScrobbleEvent) error {
err := p.scrobble(ctx, "/scrobble/stop", cfg, conn, event)
var conflict simklConflictError
if event.Completed && errors.As(err, &conflict) {
return nil
}
return err
}
// FetchWatchlist pulls Simkl's "plan to watch" list across movies, shows and
// anime. Simkl has no separate favorites concept; plan-to-watch is its
// watchlist.
func (p *Provider) FetchWatchlist(ctx context.Context, cfg watchsync.ServerConfig, conn watchsync.Connection) ([]watchsync.RemoteFavorite, error) {
now := time.Now().UTC()
var rows []watchsync.RemoteFavorite
var movies simklAllItemsResponse
if err := p.do(ctx, http.MethodGet, "/sync/all-items/movies/plantowatch?extended=full", cfg, conn.AccessToken, nil, &movies); err != nil {
return nil, err
}
for _, movie := range movies.Movies {
key := movieKey(movie.Movie.IDs)
if key == "" {
continue
}
rows = append(rows, watchsync.RemoteFavorite{
Provider: p.Key(),
ProviderItemKey: key,
Kind: historyimport.KindMovie,
Title: movie.Movie.Title,
Year: movie.Movie.Year,
IMDbID: movie.Movie.IDs.IMDb,
TMDBID: intString(movie.Movie.IDs.TMDB),
TVDBID: intString(movie.Movie.IDs.TVDB),
FavoritedAt: now,
})
}
for _, path := range []string{
"/sync/all-items/shows/plantowatch?extended=full",
"/sync/all-items/anime/plantowatch?extended=full",
} {
var payload simklAllItemsResponse
if err := p.do(ctx, http.MethodGet, path, cfg, conn.AccessToken, nil, &payload); err != nil {
return nil, err
}
for _, show := range append(payload.Shows, payload.Anime...) {
key := showKey(show.Show.IDs)
if key == "" {
continue
}
rows = append(rows, watchsync.RemoteFavorite{
Provider: p.Key(),
ProviderItemKey: key,
Kind: historyimport.KindSeries,
Title: show.Show.Title,
Year: show.Show.Year,
IMDbID: show.Show.IDs.IMDb,
TMDBID: intString(show.Show.IDs.TMDB),
TVDBID: intString(show.Show.IDs.TVDB),
FavoritedAt: now,
})
}
}
return rows, nil
}
func (p *Provider) ExportWatchlist(ctx context.Context, cfg watchsync.ServerConfig, conn watchsync.Connection, items []watchsync.LocalFavorite) (watchsync.ExportResult, error) {
return p.sendListChange(ctx, "/sync/add-to-list", cfg, conn, items, "plantowatch")
}
func (p *Provider) RemoveWatchlist(ctx context.Context, cfg watchsync.ServerConfig, conn watchsync.Connection, items []watchsync.LocalFavorite) (watchsync.ExportResult, error) {
return p.sendListChange(ctx, "/sync/remove-from-list", cfg, conn, items, "")
}
func (p *Provider) sendListChange(ctx context.Context, path string, cfg watchsync.ServerConfig, conn watchsync.Connection, items []watchsync.LocalFavorite, to string) (watchsync.ExportResult, error) {
payload := buildSimklListPayload(items, to)
if len(payload.Movies) == 0 && len(payload.Shows) == 0 {
return watchsync.ExportResult{}, nil
}
var body bytes.Buffer
if err := json.NewEncoder(&body).Encode(payload); err != nil {
return watchsync.ExportResult{}, fmt.Errorf("encode simkl list payload: %w", err)
}
if err := p.do(ctx, http.MethodPost, path, cfg, conn.AccessToken, &body, nil); err != nil {
return watchsync.ExportResult{}, err
}
result := watchsync.ExportResult{Sent: make([]string, 0, len(items)*2)}
for _, item := range items {
result.Sent = append(result.Sent, item.MediaItemID, item.ProviderItemKey)
}
return result, nil
}
func (p *Provider) fetchActivities(ctx context.Context, cfg watchsync.ServerConfig, conn watchsync.Connection) (simklActivities, error) {
var activities simklActivities
if err := p.do(ctx, http.MethodGet, "/sync/activities", cfg, conn.AccessToken, nil, &activities); err != nil {
return simklActivities{}, err
}
return activities, nil
}
func (p *Provider) sendHistory(ctx context.Context, cfg watchsync.ServerConfig, conn watchsync.Connection, plays []watchsync.LocalPlay, path string, includeWatchedAt bool) (watchsync.ExportResult, error) {
request := buildHistoryRequest(plays, includeWatchedAt)
payload := request.Payload
if len(payload.Movies) == 0 && len(payload.Shows) == 0 && len(payload.Episodes) == 0 {
return watchsync.ExportResult{}, nil
}
var body bytes.Buffer
if err := json.NewEncoder(&body).Encode(payload); err != nil {
return watchsync.ExportResult{}, fmt.Errorf("encode simkl history payload: %w", err)
}
var response simklHistoryResponse
if err := p.do(ctx, http.MethodPost, path, cfg, conn.AccessToken, &body, &response); err != nil {
return watchsync.ExportResult{}, err
}
notFound := response.notFoundHistoryIDs(request.HistoryIDsByKey)
notFoundSet := make(map[string]bool, len(notFound))
for _, historyID := range notFound {
notFoundSet[historyID] = true
}
result := watchsync.ExportResult{
Sent: make([]string, 0, len(plays)-len(notFoundSet)),
NotFound: make([]string, 0, len(notFoundSet)),
}
for _, play := range plays {
if play.HistoryID == "" {
continue
}
if notFoundSet[play.HistoryID] {
result.NotFound = append(result.NotFound, play.HistoryID)
continue
}
result.Sent = append(result.Sent, play.HistoryID)
}
return result, nil
}
func (p *Provider) scrobble(ctx context.Context, path string, cfg watchsync.ServerConfig, conn watchsync.Connection, event watchsync.ScrobbleEvent) error {
payload := buildScrobblePayload(event)
var body bytes.Buffer
if err := json.NewEncoder(&body).Encode(payload); err != nil {
return fmt.Errorf("encode simkl scrobble payload: %w", err)
}
return p.do(ctx, http.MethodPost, path, cfg, conn.AccessToken, &body, nil)
}
func (p *Provider) do(ctx context.Context, method string, path string, cfg watchsync.ServerConfig, token string, body io.Reader, out any) error {
req, err := http.NewRequestWithContext(ctx, method, p.baseURL+path, body)
if err != nil {
return fmt.Errorf("create simkl request: %w", err)
}
req.Header.Set("Content-Type", "application/json")
req.Header.Set("simkl-api-key", cfg.ClientID)
if token != "" {
req.Header.Set("Authorization", "Bearer "+token)
}
resp, err := p.client.Do(req)
if err != nil {
return fmt.Errorf("send simkl request: %w", err)
}
defer resp.Body.Close()
if resp.StatusCode == http.StatusConflict {
return simklConflictError{method: method, path: path}
}
if resp.StatusCode < http.StatusOK || resp.StatusCode >= http.StatusMultipleChoices {
return fmt.Errorf("simkl request %s %s failed: status %d", method, path, resp.StatusCode)
}
if out == nil || resp.StatusCode == http.StatusNoContent {
return nil
}
if err := json.NewDecoder(resp.Body).Decode(out); err != nil {
return fmt.Errorf("decode simkl response: %w", err)
}
return nil
}
type simklConflictError struct {
method string
path string
}
func (e simklConflictError) Error() string {
return fmt.Sprintf("simkl request %s %s conflicted", e.method, e.path)
}
type pinCodeResponse struct {
Result string `json:"result"`
DeviceCode string `json:"device_code"`
UserCode string `json:"user_code"`
VerificationURL string `json:"verification_url"`
ExpiresIn int `json:"expires_in"`
Interval int `json:"interval"`
}
type pinStatusResponse struct {
Result string `json:"result"`
Message string `json:"message"`
AccessToken string `json:"access_token"`
}
type simklActivities struct {
All string `json:"all"`
Movies simklActivityBucket `json:"movies"`
TVShows simklActivityBucket `json:"tv_shows"`
Anime simklActivityBucket `json:"anime"`
}
type simklActivityBucket struct {
All string `json:"all"`
Playback string `json:"playback"`
Watching string `json:"watching"`
Completed string `json:"completed"`
RemovedFromList string `json:"removed_from_list"`
}
type simklWatchedBucket struct {
cursorKey string
activity string
path string
allowShowTimestampFallback bool
}
type simklAllItemsResponse struct {
Movies []simklMovieItem `json:"movies"`
Shows []simklShowItem `json:"shows"`
Anime []simklShowItem `json:"anime"`
}
type simklMovieItem struct {
Status string `json:"status"`
LastWatchedAt *time.Time `json:"last_watched_at"`
Movie simklMovie `json:"movie"`
}
type simklShowItem struct {
Status string `json:"status"`
LastWatchedAt *time.Time `json:"last_watched_at"`
Show simklShow `json:"show"`
Seasons []simklSeason `json:"seasons"`
}
type simklSeason struct {
Number int `json:"number"`
Episodes []simklEpisode `json:"episodes"`
}
type simklEpisode struct {
Title string `json:"title"`
Season int `json:"season"`
Number int `json:"number"`
Episode int `json:"episode"`
TVDBSeason int `json:"tvdb_season"`
TVDBNumber int `json:"tvdb_number"`
TVDB struct {
Season int `json:"season"`
Episode int `json:"episode"`
} `json:"tvdb"`
WatchedAt *time.Time `json:"watched_at"`
IDs simklIDs `json:"ids"`
}
type simklPlayback struct {
ID int64 `json:"id"`
Type string `json:"type"`
Progress float64 `json:"progress"`
PausedAt time.Time `json:"paused_at"`
Movie simklMovie `json:"movie"`
Show simklShow `json:"show"`
Anime simklShow `json:"anime"`
Episode simklEpisode `json:"episode"`
}
type simklMovie struct {
Title string `json:"title"`
Year int `json:"year"`
IDs simklIDs `json:"ids"`
}
type simklShow struct {
Title string `json:"title"`
Year int `json:"year"`
IDs simklIDs `json:"ids"`
}
type simklIDs struct {
Simkl int `json:"simkl,omitempty"`
Slug string `json:"slug,omitempty"`
IMDb string `json:"imdb,omitempty"`
TMDB int `json:"tmdb,omitempty"`
TVDB int `json:"tvdb,omitempty"`
}
func (ids *simklIDs) UnmarshalJSON(data []byte) error {
var raw map[string]any
if err := json.Unmarshal(data, &raw); err != nil {
return err
}
ids.Simkl = intFromJSON(raw["simkl"])
ids.Slug = stringFromJSON(raw["slug"])
ids.IMDb = stringFromJSON(raw["imdb"])
ids.TMDB = intFromJSON(raw["tmdb"])
ids.TVDB = intFromJSON(raw["tvdb"])
return nil
}
type simklHistoryPayload struct {
Movies []simklHistoryMovie `json:"movies,omitempty"`
Shows []simklHistoryShow `json:"shows,omitempty"`
Episodes []simklHistoryEpisode `json:"episodes,omitempty"`
}
type simklHistoryMovie struct {
Title string `json:"title,omitempty"`
Year int `json:"year,omitempty"`
WatchedAt string `json:"watched_at,omitempty"`
IDs simklIDs `json:"ids,omitempty"`
}
type simklHistoryShow struct {
Title string `json:"title,omitempty"`
Year int `json:"year,omitempty"`
IDs simklIDs `json:"ids,omitempty"`
Seasons []simklHistorySeason `json:"seasons,omitempty"`
}
type simklHistorySeason struct {
Number int `json:"number"`
Episodes []simklHistoryEpisode `json:"episodes,omitempty"`
WatchedAt string `json:"watched_at,omitempty"`
}
type simklHistoryEpisode struct {
Number int `json:"number,omitempty"`
WatchedAt string `json:"watched_at,omitempty"`
IDs simklIDs `json:"ids,omitempty"`
}
type simklHistoryResponse struct {
NotFound struct {
Movies []simklHistoryMovie `json:"movies"`
Shows []simklHistoryShow `json:"shows"`
Episodes []simklHistoryEpisode `json:"episodes"`
} `json:"not_found"`
}
func (r simklHistoryResponse) notFoundHistoryIDs(historyIDsByKey map[string][]string) []string {
seen := make(map[string]bool)
var historyIDs []string
addByKeys := func(keys []string) {
for _, key := range keys {
for _, historyID := range historyIDsByKey[key] {
if historyID == "" || seen[historyID] {
continue
}
seen[historyID] = true
historyIDs = append(historyIDs, historyID)
}
}
}
for _, movie := range r.NotFound.Movies {
addByKeys(historyMovieMatchKeys(movie))
}
for _, show := range r.NotFound.Shows {
addByKeys(historyShowMatchKeys(show))
}
for _, episode := range r.NotFound.Episodes {
addByKeys(historyStandaloneEpisodeMatchKeys(episode))
}
return historyIDs
}
func (p *Provider) addRemovedListWarning(
conn watchsync.Connection,
activity string,
cursorKey string,
batch *watchsync.WatchedImportBatch,
) {
if activity == "" || conn.SyncCursors[cursorKey] == activity {
return
}
batch.Warnings = append(batch.Warnings, "simkl removed_from_list changed; removals are not imported")
batch.UpdatedCursors[cursorKey] = activity
}
func watchedBuckets(activities simklActivities) []simklWatchedBucket {
return []simklWatchedBucket{
{
cursorKey: simklCursorInboundMoviesCompleted,
activity: activities.Movies.Completed,
path: "/sync/all-items/movies/completed?extended=full&episode_watched_at=yes",
},
{
cursorKey: simklCursorInboundShowsWatching,
activity: activities.TVShows.Watching,
path: "/sync/all-items/shows/watching?extended=full&episode_watched_at=yes",
},
{
cursorKey: simklCursorInboundShowsCompleted,
activity: activities.TVShows.Completed,
path: "/sync/all-items/shows/completed?extended=full&episode_watched_at=yes",
allowShowTimestampFallback: true,
},
{
cursorKey: simklCursorInboundAnimeWatching,
activity: activities.Anime.Watching,
path: "/sync/all-items/anime/watching?extended=full_anime_seasons&episode_watched_at=yes",
},
{
cursorKey: simklCursorInboundAnimeCompleted,
activity: activities.Anime.Completed,
path: "/sync/all-items/anime/completed?extended=full_anime_seasons&episode_watched_at=yes",
allowShowTimestampFallback: true,
},
}
}
func shouldSkipSimklBucket(previous string, activity string) bool {
if previous == "" {
return false
}
if strings.TrimSpace(activity) == "" {
return true
}
return previous == activity
}
func appendDateFrom(path string, dateFrom string) string {
if strings.TrimSpace(dateFrom) == "" {
return path
}
separator := "?"
if strings.Contains(path, "?") {
separator = "&"
}
return path + separator + "date_from=" + url.QueryEscape(dateFrom)
}
func watchedRowsFromAllItems(payload simklAllItemsResponse, allowShowTimestampFallback bool) ([]watchsync.RemoteWatch, []string) {
rows := make([]watchsync.RemoteWatch, 0, len(payload.Movies))
var warnings []string
for _, movie := range payload.Movies {
if movie.Status != "" && movie.Status != "completed" {
continue
}
if movie.LastWatchedAt == nil {
continue
}
key := movieKey(movie.Movie.IDs)
if key == "" {
warnings = append(warnings, "simkl watched movie skipped because it has no usable external id")
continue
}
rows = append(rows, watchsync.RemoteWatch{
Provider: "simkl",
ProviderItemKey: key,
Kind: historyimport.KindMovie,
Title: movie.Movie.Title,
Year: movie.Movie.Year,
IMDbID: movie.Movie.IDs.IMDb,
TMDBID: intString(movie.Movie.IDs.TMDB),
TVDBID: intString(movie.Movie.IDs.TVDB),
PlayCount: 1,
LastWatchedAt: movie.LastWatchedAt,
})
}
for _, show := range append(payload.Shows, payload.Anime...) {
for _, season := range show.Seasons {
for _, episode := range season.Episodes {
watchedAt := episode.WatchedAt
if watchedAt == nil && allowShowTimestampFallback && show.Status == "completed" {
watchedAt = show.LastWatchedAt
}
if watchedAt == nil {
continue
}
seasonNumber, number := episodeNumbers(episode, season.Number)
key := episodeKey(show.Show.IDs, seasonNumber, number, episode.IDs)
if key == "" {
warnings = append(warnings, "simkl watched episode skipped because it has no usable external id path")
continue
}
rows = append(rows, watchsync.RemoteWatch{
Provider: "simkl",
ProviderItemKey: key,
Kind: historyimport.KindEpisode,
Title: episode.Title,
IMDbID: episode.IDs.IMDb,
TMDBID: intString(episode.IDs.TMDB),
TVDBID: intString(episode.IDs.TVDB),
SeriesTitle: show.Show.Title,
SeriesYear: show.Show.Year,
SeriesIMDbID: show.Show.IDs.IMDb,
SeriesTMDBID: intString(show.Show.IDs.TMDB),
SeriesTVDBID: intString(show.Show.IDs.TVDB),
SeasonNumber: seasonNumber,
EpisodeNumber: number,
PlayCount: 1,
LastWatchedAt: watchedAt,
})
}
}
}
return rows, warnings
}
func progressRowsFromPlayback(payload []simklPlayback, provider string) ([]watchsync.RemoteProgress, []string, bool) {
rows := make([]watchsync.RemoteProgress, 0, len(payload))
var warnings []string
hasAnimeRows := false
for _, item := range payload {
switch item.Type {
case "movie":
key := movieKey(item.Movie.IDs)
if key == "" {
warnings = append(warnings, "simkl playback movie skipped because it has no usable external id")
continue
}
rows = append(rows, watchsync.RemoteProgress{
Provider: provider,
ProviderItemKey: key,
Kind: historyimport.KindMovie,
Title: item.Movie.Title,
Year: item.Movie.Year,
IMDbID: item.Movie.IDs.IMDb,
TMDBID: intString(item.Movie.IDs.TMDB),
TVDBID: intString(item.Movie.IDs.TVDB),
ProgressPercent: item.Progress,
PausedAt: item.PausedAt,
})
case "episode", "show", "anime":
show := item.Show
if item.Type == "anime" || show.Title == "" && item.Anime.Title != "" {
show = item.Anime
hasAnimeRows = true
}
season, episode := episodeNumbers(item.Episode, 0)
key := episodeKey(show.IDs, season, episode, item.Episode.IDs)
if key == "" {
warnings = append(warnings, "simkl playback episode skipped because it has no usable external id path")
continue
}
rows = append(rows, watchsync.RemoteProgress{
Provider: provider,
ProviderItemKey: key,
Kind: historyimport.KindEpisode,
Title: item.Episode.Title,
SeriesTitle: show.Title,
SeriesYear: show.Year,
SeriesIMDbID: show.IDs.IMDb,
SeriesTMDBID: intString(show.IDs.TMDB),
SeriesTVDBID: intString(show.IDs.TVDB),
SeasonNumber: season,
EpisodeNumber: episode,
ProgressPercent: item.Progress,
PausedAt: item.PausedAt,
})
}
}
return rows, warnings, hasAnimeRows
}
func episodeNumbers(episode simklEpisode, seasonFallback int) (int, int) {
season := episode.TVDB.Season
number := episode.TVDB.Episode
if season == 0 {
season = episode.TVDBSeason
}
if number == 0 {
number = episode.TVDBNumber
}
if season == 0 {
season = episode.Season
}
if season == 0 {
season = seasonFallback
}
if number == 0 {
number = episode.Number
}
if number == 0 {
number = episode.Episode
}
return season, number
}
func oldestCursor(values ...string) string {
var oldest string
var oldestTime time.Time
for _, value := range values {
if strings.TrimSpace(value) == "" {
continue
}
parsed, err := time.Parse(time.RFC3339, value)
if err != nil {
if oldest == "" {
oldest = value
}
continue
}
if oldest == "" || parsed.Before(oldestTime) {
oldest = value
oldestTime = parsed
}
}
return oldest
}
type simklHistoryRequest struct {
Payload simklHistoryPayload
HistoryIDsByKey map[string][]string
}
func buildHistoryRequest(plays []watchsync.LocalPlay, includeWatchedAt bool) simklHistoryRequest {
request := simklHistoryRequest{
HistoryIDsByKey: make(map[string][]string),
}
for _, play := range plays {
watchedAt := ""
if includeWatchedAt && !play.WatchedAt.IsZero() {
watchedAt = play.WatchedAt.UTC().Format(time.RFC3339)
}
switch play.Kind {
case historyimport.KindMovie:
movie := simklHistoryMovie{
Title: play.Title,
Year: play.Year,
WatchedAt: watchedAt,
IDs: idsFromLocal(play.IMDbID, play.TMDBID, play.TVDBID),
}
request.Payload.Movies = append(request.Payload.Movies, movie)
request.addHistoryID(play.HistoryID, historyMovieMatchKeys(movie))
case historyimport.KindEpisode:
show := simklHistoryShow{
Title: play.SeriesTitle,
Year: play.SeriesYear,
IDs: idsFromLocal(play.SeriesIMDbID, play.SeriesTMDBID, play.SeriesTVDBID),
Seasons: []simklHistorySeason{{
Number: play.SeasonNumber,
Episodes: []simklHistoryEpisode{{
Number: play.EpisodeNumber,
WatchedAt: watchedAt,
IDs: idsFromLocal(play.IMDbID, play.TMDBID, play.TVDBID),
}},
}},
}
request.Payload.Shows = append(request.Payload.Shows, show)
request.addHistoryID(play.HistoryID, historyShowRequestMatchKeys(show))
}
}
return request
}
func buildHistoryPayload(plays []watchsync.LocalPlay, includeWatchedAt bool) simklHistoryPayload {
return buildHistoryRequest(plays, includeWatchedAt).Payload
}
func (r simklHistoryRequest) addHistoryID(historyID string, keys []string) {
if historyID == "" {
return
}
for _, key := range keys {
if key == "" {
continue
}
r.HistoryIDsByKey[key] = append(r.HistoryIDsByKey[key], historyID)
}
}
func historyMovieMatchKeys(movie simklHistoryMovie) []string {
var keys []string
watchedAt := strings.TrimSpace(movie.WatchedAt)
for _, idKey := range historyIDMatchKeys(movie.IDs) {
keys = appendHistoryWatchedVariants(keys, "movie:id:"+idKey, watchedAt)
}
if title := normalizedHistoryTitle(movie.Title); title != "" && movie.Year > 0 {
keys = appendHistoryWatchedVariants(keys, fmt.Sprintf("movie:title:%s:%d", title, movie.Year), watchedAt)
}
return keys
}
func historyShowMatchKeys(show simklHistoryShow) []string {
if len(show.Seasons) == 0 {
return historyShowOnlyMatchKeys(show)
}
return historyShowEpisodeMatchKeys(show)
}
func historyShowRequestMatchKeys(show simklHistoryShow) []string {
keys := historyShowOnlyMatchKeys(show)
return append(keys, historyShowEpisodeMatchKeys(show)...)
}
func historyShowOnlyMatchKeys(show simklHistoryShow) []string {
var keys []string
for _, showKey := range historyShowIdentityKeys(show) {
keys = append(keys, "show:"+showKey)
}
return keys
}
func historyShowEpisodeMatchKeys(show simklHistoryShow) []string {
var keys []string
showKeys := historyShowIdentityKeys(show)
for _, season := range show.Seasons {
for _, episode := range season.Episodes {
for _, showKey := range showKeys {
keys = append(keys, historyShowEpisodeIdentityKeys(showKey, season.Number, episode)...)
}
keys = append(keys, historyStandaloneEpisodeMatchKeys(episode)...)
}
}
return keys
}
func historyShowIdentityKeys(show simklHistoryShow) []string {
var keys []string
for _, idKey := range historyIDMatchKeys(show.IDs) {
keys = append(keys, "id:"+idKey)
}
if title := normalizedHistoryTitle(show.Title); title != "" && show.Year > 0 {
keys = append(keys, fmt.Sprintf("title:%s:%d", title, show.Year))
}
return keys
}
func historyShowEpisodeIdentityKeys(showKey string, seasonNumber int, episode simklHistoryEpisode) []string {
base := fmt.Sprintf("episode:show:%s:s%d:e%d", showKey, seasonNumber, episode.Number)
return appendHistoryWatchedVariants(nil, base, strings.TrimSpace(episode.WatchedAt))
}
func historyStandaloneEpisodeMatchKeys(episode simklHistoryEpisode) []string {
var keys []string
watchedAt := strings.TrimSpace(episode.WatchedAt)
for _, idKey := range historyIDMatchKeys(episode.IDs) {
keys = appendHistoryWatchedVariants(keys, "episode:id:"+idKey, watchedAt)
}
return keys
}
func appendHistoryWatchedVariants(keys []string, base string, watchedAt string) []string {
if base == "" {
return keys
}
if watchedAt != "" {
keys = append(keys, base+":watched:"+watchedAt)
}
return append(keys, base)
}
func historyIDMatchKeys(ids simklIDs) []string {
keys := make([]string, 0, 4)
if ids.IMDb != "" {
keys = append(keys, "imdb:"+ids.IMDb)
}
if ids.TMDB > 0 {
keys = append(keys, "tmdb:"+strconv.Itoa(ids.TMDB))
}
if ids.TVDB > 0 {
keys = append(keys, "tvdb:"+strconv.Itoa(ids.TVDB))
}
if ids.Simkl > 0 {
keys = append(keys, "simkl:"+strconv.Itoa(ids.Simkl))
}
return keys
}
func normalizedHistoryTitle(title string) string {
return strings.Join(strings.Fields(strings.ToLower(title)), " ")
}
func buildScrobblePayload(event watchsync.ScrobbleEvent) map[string]any {
progress := 0.0
if event.DurationSeconds > 0 {
progress = event.PositionSeconds / event.DurationSeconds * 100
}
payload := map[string]any{"progress": progress}
switch event.Kind {
case historyimport.KindEpisode:
payload["show"] = map[string]any{"ids": idsFromLocal(event.SeriesIMDbID, event.SeriesTMDBID, event.SeriesTVDBID)}
payload["episode"] = map[string]any{
"season": event.SeasonNumber,
"number": event.EpisodeNumber,
"ids": idsFromLocal(event.IMDbID, event.TMDBID, event.TVDBID),
}
default:
payload["movie"] = map[string]any{"ids": idsFromLocal(event.IMDbID, event.TMDBID, event.TVDBID)}
}
return payload
}
func idsFromLocal(imdbID, tmdbID, tvdbID string) simklIDs {
return simklIDs{IMDb: imdbID, TMDB: parseInt(tmdbID), TVDB: parseInt(tvdbID)}
}
func intString(value int) string {
if value == 0 {
return ""
}
return strconv.Itoa(value)
}
func movieKey(ids simklIDs) string {
switch {
case ids.IMDb != "":
return "imdb:" + ids.IMDb
case ids.TMDB > 0:
return "tmdb:" + strconv.Itoa(ids.TMDB)
case ids.TVDB > 0:
return "tvdb:" + strconv.Itoa(ids.TVDB)
case ids.Simkl > 0:
return "simkl:" + strconv.Itoa(ids.Simkl)
default:
return ""
}
}
func showKey(ids simklIDs) string {
switch {
case ids.TVDB > 0:
return "tvdb:" + strconv.Itoa(ids.TVDB)
case ids.TMDB > 0:
return "tmdb:" + strconv.Itoa(ids.TMDB)
case ids.IMDb != "":
return "imdb:" + ids.IMDb
case ids.Simkl > 0:
return "simkl:" + strconv.Itoa(ids.Simkl)
default:
return ""
}
}
type simklListPayload struct {
Movies []simklListItem `json:"movies,omitempty"`
Shows []simklListItem `json:"shows,omitempty"`
}
type simklListItem struct {
To string `json:"to,omitempty"`
IDs simklIDs `json:"ids"`
}
func buildSimklListPayload(items []watchsync.LocalFavorite, to string) simklListPayload {
var payload simklListPayload
for _, item := range items {
ids := idsFromLocal(item.IMDbID, item.TMDBID, item.TVDBID)
if ids == (simklIDs{}) {
ids = idsFromProviderItemKey(item.ProviderItemKey)
}
if ids == (simklIDs{}) {
continue
}
ref := simklListItem{To: to, IDs: ids}
switch item.Kind {
case historyimport.KindMovie:
payload.Movies = append(payload.Movies, ref)
case historyimport.KindSeries:
payload.Shows = append(payload.Shows, ref)
}
}
return payload
}
func idsFromProviderItemKey(key string) simklIDs {
prefix, value, ok := strings.Cut(key, ":")
if !ok || value == "" {
return simklIDs{}
}
switch prefix {
case "imdb":
return simklIDs{IMDb: value}
case "tmdb":
return simklIDs{TMDB: parseInt(value)}
case "tvdb":
return simklIDs{TVDB: parseInt(value)}
case "simkl":
return simklIDs{Simkl: parseInt(value)}
default:
return simklIDs{}
}
}
func episodeKey(showIDs simklIDs, season, episode int, episodeIDs simklIDs) string {
switch {
case episodeIDs.TVDB > 0:
return "tvdb:" + strconv.Itoa(episodeIDs.TVDB)
case episodeIDs.TMDB > 0:
return "tmdb:" + strconv.Itoa(episodeIDs.TMDB)
case episodeIDs.Simkl > 0:
return "simkl:" + strconv.Itoa(episodeIDs.Simkl)
case showIDs.TVDB > 0:
return fmt.Sprintf("show:tvdb:%d:s%d:e%d", showIDs.TVDB, season, episode)
case showIDs.TMDB > 0:
return fmt.Sprintf("show:tmdb:%d:s%d:e%d", showIDs.TMDB, season, episode)
case showIDs.IMDb != "":
return fmt.Sprintf("show:imdb:%s:s%d:e%d", showIDs.IMDb, season, episode)
default:
return ""
}
}
func parseInt(value string) int {
parsed, _ := strconv.Atoi(value)
return parsed
}
func intFromJSON(value any) int {
switch v := value.(type) {
case float64:
return int(v)
case string:
parsed, _ := strconv.Atoi(v)
return parsed
default:
return 0
}
}
func stringFromJSON(value any) string {
if s, ok := value.(string); ok {
return s
}
return ""
}