summaryrefslogtreecommitdiff
path: root/hostnix/elmo/pinchflat.nix
blob: 4374607498580d2a956b028288fd36a38b57fe4c (plain)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
{ ... }:

# Self-hosted YouTube downloader: https://github.com/kieraneglin/pinchflat
#
# Coexists with ytdl-sub (media.nix) rather than replacing it. Each gets its
# own tree under /srv/media/incoming so the two never manage the same files.

# Manual configuration:
# - Jellyfin: add Media Library /srv/media/incoming/Pinchflat (Shows)
# - Copy feed URLs from https://pf.elmo.mou.fo, never from localhost: the XML
#   is generated with whatever host and X-Forwarded-Proto the request carried.

let
  mediaDir = "/srv/media/incoming/Pinchflat";
  upstream = "http://127.0.0.1:8945";

  # Podcast clients can't log in, so the feed endpoints have to sit outside
  # oauth2-proxy. These are exactly the routes pinchflat itself serves
  # unauthenticated (the maybe_basic_auth scope in router.ex); each is
  # addressed by an unguessable UUID rather than a sequential id, which is the
  # only thing keeping them private.
  #
  # The trailing extension is optional because the feed builder emits
  # feed.xml, stream.mp4, episode_image.jpg and so on, while endpoint.ex
  # strips the extension again before routing.
  feedRoutes = "~* ^/(sources/[^/]+/feed(_image)?|media/[^/]+/(stream|episode_image))(\\.[a-zA-Z0-9]+)?$";

  # Lists every source's feed for bulk import. Unlike the routes above this one
  # is not public: pinchflat 401s unless ?route_token= matches.
  opmlRoute = "~* ^/sources/opml(\\.[a-zA-Z0-9]+)?$";
in
{
  # Setgid so downloads land in the media group, as Jellyfin expects.
  systemd.tmpfiles.rules = [
    "d ${mediaDir} 2775 pinchflat media -"
  ];

  services.pinchflat = {
    enable = true;
    inherit mediaDir;
    # Uses a weak built-in SECRET_KEY_BASE instead of a hand-managed
    # /var/secrets file. Acceptable here: the port is not opened in the
    # firewall and the vhost is behind oauth2-proxy.
    selfhosted = true;
    # A no-op while BASIC_AUTH_* is unset, since authentication is nginx's job
    # (see feedRoutes). Set so the feeds keep working if basic auth is ever
    # turned on.
    extraConfig.EXPOSE_FEED_ENDPOINTS = "yes";
  };

  users.users.pinchflat = {
    extraGroups = [ "media" ];
  };

  # TODO allocate domain?
  services.nginx.virtualHosts."pf.elmo.mou.fo" = {
    useACMEHost = "elmo.mou.fo";
    forceSSL = true;
    locations = {
      "/" = {
        # Phoenix listens on all interfaces; only nginx should reach it.
        proxyPass = upstream;
        # LiveView drives the whole UI over a websocket.
        proxyWebsockets = true;
      };
      ${feedRoutes} = {
        proxyPass = upstream;
        extraConfig = ''
          auth_request off;
          # Whole episodes stream through here, and the app serves its own
          # Range requests; don't spool them into nginx temp files first.
          proxy_buffering off;
        '';
      };
      ${opmlRoute} = {
        proxyPass = upstream;
        extraConfig = ''
          auth_request off;
        '';
      };
    };
  };

  services.oauth2-proxy.nginx.virtualHosts."pf.elmo.mou.fo" = { };
}