Move scrobble refresh infra to Oban
This commit is contained in:
@@ -58,11 +58,6 @@ config :music_library, LastFm,
|
||||
user_agent: user_agent,
|
||||
api_cooldown: 500
|
||||
|
||||
config :music_library, MusicLibrary.ListeningStats.Refresh,
|
||||
auto_refresh: true,
|
||||
# refresh every 5 minutes
|
||||
refresh_interval: System.convert_time_unit(300, :second, :millisecond)
|
||||
|
||||
config :music_library, MusicBrainz, user_agent: user_agent, api_cooldown: 500
|
||||
|
||||
config :music_library, Discogs,
|
||||
|
||||
+9
-2
@@ -81,8 +81,15 @@ config :music_library, monitoring_routes: true
|
||||
|
||||
config :music_library, MusicLibrary.Mailer, adapter: Swoosh.Adapters.Local
|
||||
|
||||
config :music_library, MusicLibrary.ListeningStats.Refresh,
|
||||
refresh_interval: System.convert_time_unit(500, :second, :millisecond)
|
||||
config :music_library, Oban,
|
||||
plugins: [
|
||||
{Oban.Plugins.Cron,
|
||||
timezone: "Europe/London",
|
||||
crontab: [
|
||||
# every 8 minutes (closest cron equivalent to previous 500s dev interval)
|
||||
{"*/8 * * * *", MusicLibrary.Worker.RefreshScrobbles}
|
||||
]}
|
||||
]
|
||||
|
||||
# Do not include metadata nor timestamps in development logs
|
||||
config :logger, :default_formatter, format: "[$level] $message\n"
|
||||
|
||||
+3
-1
@@ -46,7 +46,9 @@ config :music_library, Oban,
|
||||
{"0 9 1 * *", MusicLibrary.Worker.ArtistRefreshAllDiscogsData},
|
||||
{"0 10 1 * *", MusicLibrary.Worker.ArtistRefreshAllWikipediaData},
|
||||
# daily at 7 am
|
||||
{"0 7 * * *", MusicLibrary.Worker.SendRecordsOnThisDayEmail}
|
||||
{"0 7 * * *", MusicLibrary.Worker.SendRecordsOnThisDayEmail},
|
||||
# every 5 minutes
|
||||
{"*/5 * * * *", MusicLibrary.Worker.RefreshScrobbles}
|
||||
]}
|
||||
]
|
||||
|
||||
|
||||
@@ -62,8 +62,6 @@ config :music_library, LastFm,
|
||||
],
|
||||
api_cooldown: 0
|
||||
|
||||
config :music_library, MusicLibrary.ListeningStats.Refresh, auto_refresh: false
|
||||
|
||||
config :music_library, MusicBrainz,
|
||||
req_options: [
|
||||
plug: {Req.Test, MusicBrainz.API},
|
||||
|
||||
@@ -39,7 +39,6 @@ MusicLibrary.Application (one_for_one)
|
||||
├── Ecto.Migrator # Auto-migration on boot
|
||||
├── Task.Supervisor (MusicLibrary.TaskSupervisor)
|
||||
├── Phoenix.PubSub (:music_library)
|
||||
├── MusicLibrary.ListeningStats.Refresh # GenServer, periodic scrobble fetch
|
||||
└── MusicLibraryWeb.Endpoint
|
||||
```
|
||||
|
||||
@@ -198,6 +197,7 @@ stubbed via `Req.Test` (configured in `config/test.exs`).
|
||||
| `ArtistRefreshAllMusicBrainzData` | music_brainz | Manual / cron (bulk refresh via Artists.Batch) |
|
||||
| `ArtistRefreshAllDiscogsData` | discogs | Manual / cron (bulk refresh via Artists.Batch) |
|
||||
| `ArtistRefreshAllWikipediaData` | wikipedia | Manual / cron (bulk refresh via Artists.Batch) |
|
||||
| `RefreshScrobbles` | last_fm | Cron / manual (fetch recent Last.fm scrobbles) |
|
||||
| `BackfillScrobbledTracks` | heavy_writes | Manual (self-chaining batch import) |
|
||||
| `SendRecordsOnThisDayEmail` | default | Cron (daily "records on this day" email) |
|
||||
|
||||
@@ -216,6 +216,7 @@ stubbed via `Req.Test` (configured in `config/test.exs`).
|
||||
| Monthly 1st, 9 AM | `ArtistRefreshAllDiscogsData` | discogs |
|
||||
| Monthly 1st, 10 AM | `ArtistRefreshAllWikipediaData` | wikipedia |
|
||||
| Daily 7 AM | `SendRecordsOnThisDayEmail` | default |
|
||||
| Every 5 min | `RefreshScrobbles` | last_fm |
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -24,9 +24,6 @@ defmodule MusicLibrary.Application do
|
||||
repos: Application.fetch_env!(:music_library, :ecto_repos), skip: skip_migrations?()},
|
||||
{Task.Supervisor, name: MusicLibrary.TaskSupervisor},
|
||||
{Phoenix.PubSub, name: MusicLibrary.PubSub},
|
||||
{MusicLibrary.ListeningStats.Refresh,
|
||||
{LastFm.Config.resolve(:music_library),
|
||||
Application.fetch_env!(:music_library, MusicLibrary.ListeningStats.Refresh)}},
|
||||
# Start a worker by calling: MusicLibrary.Worker.start_link(arg)
|
||||
# {MusicLibrary.Worker, arg},
|
||||
# Start to serve requests, typically the last entry
|
||||
|
||||
@@ -15,7 +15,6 @@ defmodule MusicLibrary.ListeningStats do
|
||||
Artists,
|
||||
BackgroundRepo,
|
||||
Collection,
|
||||
ListeningStats.Refresh,
|
||||
Records.ArtistRecord,
|
||||
Records.Record,
|
||||
Repo,
|
||||
@@ -76,9 +75,10 @@ defmodule MusicLibrary.ListeningStats do
|
||||
Phoenix.PubSub.subscribe(MusicLibrary.PubSub, "listening_stats:update")
|
||||
end
|
||||
|
||||
@spec refresh() :: :ok | {:error, term()}
|
||||
@spec refresh() :: {:ok, Oban.Job.t()} | {:error, Ecto.Changeset.t()}
|
||||
def refresh do
|
||||
Refresh.refresh()
|
||||
Worker.RefreshScrobbles.new(%{})
|
||||
|> Oban.insert()
|
||||
end
|
||||
|
||||
@spec lowest_scrobbled_at_uts() :: integer() | nil
|
||||
|
||||
@@ -1,101 +0,0 @@
|
||||
defmodule MusicLibrary.ListeningStats.Refresh do
|
||||
@moduledoc """
|
||||
A GenServer that manages periodic refreshing of Last.fm scrobbled tracks.
|
||||
|
||||
Fetches recent tracks from Last.fm at configurable intervals and persists
|
||||
them via `MusicLibrary.ListeningStats.update/1`.
|
||||
|
||||
## Configuration
|
||||
|
||||
Accepts a tuple of `{LastFm.Config.t(), keyword()}` where the keyword list contains:
|
||||
- `auto_refresh` — when true, automatically starts refreshing on init
|
||||
- `refresh_interval` — time in milliseconds between refresh attempts
|
||||
|
||||
## Operation Modes
|
||||
|
||||
1. Automatic Mode (`auto_refresh: true`):
|
||||
- Starts refreshing immediately on initialization
|
||||
- Continues to refresh at the configured interval
|
||||
- Handles failures gracefully by continuing to retry
|
||||
|
||||
2. Manual Mode (`auto_refresh: false`):
|
||||
- Server remains dormant on initialization
|
||||
- Refreshes only occur via explicit `refresh/0` calls
|
||||
"""
|
||||
|
||||
use GenServer
|
||||
|
||||
alias LastFm.{API, Config}
|
||||
alias MusicLibrary.ListeningStats
|
||||
|
||||
@type state :: %{
|
||||
api_config: Config.t(),
|
||||
auto_refresh: boolean(),
|
||||
refresh_interval: pos_integer()
|
||||
}
|
||||
|
||||
@spec start_link({Config.t(), keyword()}) :: GenServer.on_start()
|
||||
def start_link({api_config, refresh_opts}) do
|
||||
state = %{
|
||||
api_config: api_config,
|
||||
auto_refresh: Keyword.get(refresh_opts, :auto_refresh, true),
|
||||
refresh_interval: Keyword.get(refresh_opts, :refresh_interval, 60_000)
|
||||
}
|
||||
|
||||
GenServer.start_link(__MODULE__, state, name: __MODULE__)
|
||||
end
|
||||
|
||||
@spec refresh() :: :ok
|
||||
def refresh do
|
||||
GenServer.call(__MODULE__, :refresh, 10_000)
|
||||
end
|
||||
|
||||
@impl true
|
||||
@spec init(state) :: {:ok, state, {:continue, :refresh}} | :ignore
|
||||
def init(state) do
|
||||
if state.auto_refresh do
|
||||
{:ok, state, {:continue, :refresh}}
|
||||
else
|
||||
:ignore
|
||||
end
|
||||
end
|
||||
|
||||
@impl true
|
||||
@spec handle_call(:refresh, GenServer.from(), state) ::
|
||||
{:reply, :ok | {:error, term()}, state, pos_integer()}
|
||||
def handle_call(:refresh, _from, state) do
|
||||
case API.get_recent_tracks(state.api_config) do
|
||||
{:ok, tracks} ->
|
||||
ListeningStats.update(tracks)
|
||||
{:reply, :ok, state, state.refresh_interval}
|
||||
|
||||
error ->
|
||||
{:reply, error, state, state.refresh_interval}
|
||||
end
|
||||
end
|
||||
|
||||
@impl true
|
||||
@spec handle_continue(atom(), state) :: {:noreply, state, pos_integer()}
|
||||
def handle_continue(:refresh, state), do: do_refresh(state)
|
||||
|
||||
@impl true
|
||||
@spec handle_info(atom(), state) :: {:noreply, state, pos_integer()}
|
||||
def handle_info(:refresh, state), do: do_refresh(state)
|
||||
|
||||
def handle_info(:timeout, state), do: do_refresh(state)
|
||||
|
||||
defp do_refresh(state) do
|
||||
case API.get_recent_tracks(state.api_config) do
|
||||
{:ok, tracks} ->
|
||||
ListeningStats.update(tracks)
|
||||
{:noreply, state, state.refresh_interval}
|
||||
|
||||
{:error, error} ->
|
||||
if API.ErrorResponse.retryable_error?(error) do
|
||||
{:noreply, state, API.ErrorResponse.retry_delay(error)}
|
||||
else
|
||||
{:stop, error, state}
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,35 @@
|
||||
defmodule MusicLibrary.Worker.RefreshScrobbles do
|
||||
@moduledoc """
|
||||
Fetches recent scrobbled tracks from Last.fm and persists them.
|
||||
|
||||
Scheduled via Oban Cron and can be triggered manually via
|
||||
`MusicLibrary.ListeningStats.refresh/0`.
|
||||
"""
|
||||
|
||||
use Oban.Worker,
|
||||
queue: :last_fm,
|
||||
max_attempts: 3,
|
||||
unique: [period: 60, states: [:available, :scheduled, :executing]]
|
||||
|
||||
alias LastFm.{API, Config}
|
||||
alias MusicLibrary.ListeningStats
|
||||
|
||||
@impl Oban.Worker
|
||||
def perform(_job) do
|
||||
api_config = Config.resolve(:music_library)
|
||||
|
||||
case API.get_recent_tracks(api_config) do
|
||||
{:ok, tracks} ->
|
||||
ListeningStats.update(tracks)
|
||||
:ok
|
||||
|
||||
{:error, %API.ErrorResponse{error: error} = resp} ->
|
||||
if API.ErrorResponse.retryable_error?(error) do
|
||||
seconds = error |> API.ErrorResponse.retry_delay() |> div(1000)
|
||||
{:snooze, seconds}
|
||||
else
|
||||
{:cancel, inspect(resp)}
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
Reference in New Issue
Block a user