1{ self ? null }:
2{ config, lib, pkgs, ... }:
3
4let
5 inherit (lib) mkEnableOption mkIf mkOption types literalExpression;
6
7 cfg = config.services.tealfm-piper;
8
9 settingsFormat = pkgs.formats.keyValue { };
10
11 derivedSettings = lib.optionalAttrs (cfg.settings.SERVER_ROOT_URL != null) {
12 ATPROTO_CLIENT_ID =
13 cfg.settings.ATPROTO_CLIENT_ID or "${cfg.settings.SERVER_ROOT_URL}/oauth-client-metadata.json";
14 ATPROTO_METADATA_URL =
15 cfg.settings.ATPROTO_METADATA_URL or "${cfg.settings.SERVER_ROOT_URL}/oauth-client-metadata.json";
16 ATPROTO_CALLBACK_URL =
17 cfg.settings.ATPROTO_CALLBACK_URL or "${cfg.settings.SERVER_ROOT_URL}/callback/atproto";
18 CALLBACK_SPOTIFY =
19 cfg.settings.CALLBACK_SPOTIFY or "${cfg.settings.SERVER_ROOT_URL}/callback/spotify";
20 };
21
22 dbPathDefault = lib.optionalAttrs (cfg.settings.DB_PATH == null) {
23 DB_PATH = "${cfg.dataDir}/piper.db";
24 };
25
26 allowedDidsString = lib.optionalAttrs (cfg.settings.ALLOWED_DIDS != null) {
27 ALLOWED_DIDS = lib.concatStringsSep " " cfg.settings.ALLOWED_DIDS;
28 };
29
30 finalSettings = lib.filterAttrs (_: v: v != null)
31 (cfg.settings // derivedSettings // dbPathDefault // allowedDidsString);
32 settingsFile = settingsFormat.generate "tealfm-piper.env" finalSettings;
33
34in {
35 meta = { maintainers = with lib.maintainers; [ ptdewey ]; };
36
37 options.services.tealfm-piper = {
38 enable = mkEnableOption "Piper - teal.fm scrobbler service";
39
40 package = mkOption {
41 type = types.package;
42 default = if self != null then
43 self.packages.${pkgs.stdenv.hostPlatform.system}.tealfm-piper
44 else
45 pkgs.tealfm-piper;
46 defaultText = literalExpression "pkgs.tealfm-piper";
47 description = "The piper package to use.";
48 };
49
50 user = mkOption {
51 type = types.str;
52 default = "tealfm-piper";
53 description = "User account under which piper runs.";
54 };
55
56 group = mkOption {
57 type = types.str;
58 default = "tealfm-piper";
59 description = "Group under which piper runs.";
60 };
61
62 dataDir = mkOption {
63 type = types.path;
64 default = "/var/lib/tealfm-piper";
65 description = "Directory where piper stores its database and data.";
66 };
67
68 settings = mkOption {
69 type = types.submodule {
70 freeformType = types.attrsOf
71 (types.oneOf [ (types.nullOr types.str) types.int types.port ]);
72
73 options = {
74 SERVER_PORT = mkOption {
75 type = types.port;
76 default = 8080;
77 description = "Port to listen on.";
78 };
79
80 SERVER_HOST = mkOption {
81 type = types.str;
82 default = "localhost";
83 description = "Host to bind to.";
84 };
85
86 SERVER_ROOT_URL = mkOption {
87 type = types.nullOr types.str;
88 default = null;
89 example = "https://piper.teal.fm";
90 description = ''
91 Public URL for OAuth callbacks. Required for OAuth flows.
92
93 Auto-derives the following URLs if not explicitly set:
94 - ATPROTO_CLIENT_ID
95 - ATPROTO_METADATA_URL
96 - ATPROTO_CALLBACK_URL
97 - CALLBACK_SPOTIFY
98 '';
99 };
100
101 DB_PATH = mkOption {
102 type = types.nullOr types.str;
103 default = null;
104 description = ''
105 Path to SQLite database file.
106 Defaults to {dataDir}/piper.db if not set.
107 '';
108 };
109
110 TRACKER_INTERVAL = mkOption {
111 type = types.int;
112 default = 30;
113 description = "Seconds between music playback checks.";
114 };
115
116 SPOTIFY_AUTH_URL = mkOption {
117 type = types.str;
118 default = "https://accounts.spotify.com/authorize";
119 description = "Spotify authorization endpoint.";
120 };
121
122 SPOTIFY_TOKEN_URL = mkOption {
123 type = types.str;
124 default = "https://accounts.spotify.com/api/token";
125 description = "Spotify token endpoint.";
126 };
127
128 SPOTIFY_SCOPES = mkOption {
129 type = types.str;
130 default = "user-read-currently-playing user-read-email";
131 description = "Spotify OAuth scopes to request.";
132 };
133
134 ALLOWED_DIDS = mkOption {
135 type = types.nullOr (types.listOf types.str);
136 default = null;
137 example =
138 literalExpression ''[ "did:plc:abcdefg" "did:web:example.com" ]'';
139 description = ''
140 List of ATProto DIDs allowed to sign in.
141 When set, restricts instance access to only these accounts.
142 Leave null to allow any ATProto account to sign in.
143 '';
144 };
145 };
146 };
147
148 default = { };
149
150 example = literalExpression ''
151 {
152 SERVER_PORT = 8080;
153 SERVER_HOST = "localhost";
154 SERVER_ROOT_URL = "https://piper.teal.fm";
155 TRACKER_INTERVAL = 30;
156 }
157 '';
158
159 description = ''
160 Configuration for piper. These will be converted to environment variables.
161 '';
162 };
163
164 environmentFiles = mkOption {
165 type = types.listOf types.path;
166 default = [ ];
167 example = literalExpression ''
168 [
169 "/run/secrets/tealfm-piper.env"
170 "/run/secrets/tealfm-piper-apple-music.env"
171 ]
172 '';
173 description = ''
174 List of files containing environment variables for secrets.
175 Files are loaded in order, with later files overriding earlier ones.
176 '';
177 };
178 };
179
180 config = mkIf cfg.enable {
181 users.users.${cfg.user} = {
182 isSystemUser = true;
183 group = cfg.group;
184 home = cfg.dataDir;
185 description = "Piper service user";
186 };
187
188 users.groups.${cfg.group} = { };
189
190 systemd.services.tealfm-piper = {
191 description = "Piper - teal.fm scrobbler service";
192 after = [ "network-online.target" ];
193 wants = [ "network-online.target" ];
194 wantedBy = [ "multi-user.target" ];
195
196 serviceConfig = {
197 Type = "simple";
198 User = cfg.user;
199 Group = cfg.group;
200 NoNewPrivileges = true;
201 PrivateTmp = true;
202 PrivateDevices = true;
203 ProtectSystem = "strict";
204 ProtectHome = true;
205 ProtectKernelTunables = true;
206 ProtectKernelModules = true;
207 ProtectControlGroups = true;
208 RestrictAddressFamilies = [ "AF_INET" "AF_INET6" "AF_UNIX" ];
209 RestrictNamespaces = true;
210 RestrictRealtime = true;
211 RestrictSUIDSGID = true;
212 LockPersonality = true;
213 ReadWritePaths = [ cfg.dataDir ];
214 StateDirectory = "tealfm-piper";
215 StateDirectoryMode = "0700";
216 WorkingDirectory = cfg.dataDir;
217 EnvironmentFile = [ settingsFile ] ++ cfg.environmentFiles;
218 ExecStart = "${cfg.package}/bin/piper";
219 Restart = "on-failure";
220 RestartSec = "10s";
221 };
222 };
223
224 assertions = [
225 {
226 assertion = (cfg.environmentFiles != [ ])
227 || (cfg.settings ? ATPROTO_CLIENT_SECRET_KEY);
228 message =
229 "services.tealfm-piper: ATPROTO_CLIENT_SECRET_KEY must be set via settings or environmentFiles";
230 }
231 {
232 assertion = (cfg.environmentFiles != [ ])
233 || (cfg.settings ? ATPROTO_CLIENT_SECRET_KEY_ID);
234 message =
235 "services.tealfm-piper: ATPROTO_CLIENT_SECRET_KEY_ID must be set via settings or environmentFiles";
236 }
237 {
238 assertion = cfg.settings.SERVER_ROOT_URL != null;
239 message =
240 "services.tealfm-piper: SERVER_ROOT_URL must be set in settings (e.g., https://piper.teal.fm)";
241 }
242 ];
243 };
244}