# Every container's log is capped: Docker keeps at most 3 files of 10 MB for each # (LOG_MAX_FILES and LOG_MAX_SIZE in .env change that), so a server that runs for months # never fills its disk with logs. `docker compose logs` reads them as before. x-logging: &logging driver: json-file options: max-size: ${LOG_MAX_SIZE:-10m} max-file: "${LOG_MAX_FILES:-3}" services: # Pulled, not built: Octo is published with every release, for amd64 and arm64, so no # install compiles it. The default tag is this release's own number (bumped with the # VERSION file; CI checks it); OCTO_IMAGE_TAG in .env picks another, such as latest. # To run Octo built from this folder instead (changes of your own, or no access to # ghcr.io), build octo-source, at the end of this file. Octo is written in Rust; the # environment below and the config folder are the ones the C# Octo read, unchanged. octo: image: ghcr.io/winters27/octo:${OCTO_IMAGE_TAG:-2026.10.11} container_name: octo restart: unless-stopped logging: *logging ports: - "5274:8080" # The dashboard on a port of its own: set ADMIN_PORT=5275 in .env, then uncomment. # 127.0.0.1 keeps it on this machine; use this machine's LAN address to reach it from home. # - "127.0.0.1:5275:5275" environment: ASPNETCORE_ENVIRONMENT: Production # ----- Subsonic / Navidrome upstream ----- # Your Navidrome server, reachable from THIS container (a LAN IP or service # name, not localhost). With the navidrome profile below (the starter stack) # this is http://navidrome:4533. Left blank, Octo looks for a server on the # network at first start and reports clearly if it finds none. Subsonic__Url: ${SUBSONIC_URL:-} # Optional Navidrome admin login. Lets Octo detect the music folder and run # authenticated rescans on its own. Leave blank to have Octo learn an admin # token from a client sign-in instead. Subsonic__AdminUsername: ${SUBSONIC_ADMIN_USERNAME:-} Subsonic__AdminPassword: ${SUBSONIC_ADMIN_PASSWORD:-} # Auto-detect the download folder from Navidrome so downloads land where it # scans. Only adopts a path Octo can actually see; otherwise falls back to # Library__DownloadPath below. Set false to always use that path verbatim. Subsonic__AutoDetectDownloadPath: ${AUTO_DETECT_DOWNLOAD_PATH:-true} Subsonic__StorageMode: ${STORAGE_MODE:-Stream} Subsonic__DownloadMode: ${DOWNLOAD_MODE:-Track} Subsonic__DownloadOnStar: ${DOWNLOAD_ON_STAR:-true} Subsonic__DownloadAlbumOnStar: ${DOWNLOAD_ALBUM_ON_STAR:-true} # Name the user who starred a track on its history entry and notification. Subsonic__RecordRequestedBy: ${RECORD_REQUESTED_BY:-true} # Also favorite a hearted song in Navidrome once it downloads, for whoever hearted it. # Off by default: a heart on a song you do not have only downloads it. A heart on a song # you already have is always a favorite. Subsonic__StarDownloadsForRequester: ${STAR_DOWNLOADS_FOR_REQUESTER:-false} # Never download a song already in the library; a lossy copy is queued for a # higher quality one instead. Subsonic__SkipOwnedSongs: ${SKIP_OWNED_SONGS:-true} Subsonic__WaitForLosslessOnPlay: ${WAIT_FOR_LOSSLESS_ON_PLAY:-false} Subsonic__LosslessWaitTimeoutSeconds: ${LOSSLESS_WAIT_TIMEOUT_SECONDS:-0} Subsonic__DownloadOnPlay: ${DOWNLOAD_ON_PLAY:-false} Subsonic__LidarrAlbumOnPlay: ${LIDARR_ALBUM_ON_PLAY:-false} Subsonic__FolderStructure: ${FOLDER_STRUCTURE:-Flat} Subsonic__DownloadSource: ${DOWNLOAD_SOURCE:-Soulseek} Subsonic__UseLocalStaging: ${USE_LOCAL_STAGING:-false} Subsonic__ExplicitFilter: ${EXPLICIT_FILTER:-All} Subsonic__CacheDurationHours: ${CACHE_DURATION_HOURS:-1} Subsonic__EnableExternalPlaylists: ${ENABLE_EXTERNAL_PLAYLISTS:-false} Subsonic__EnableSearchDiscovery: ${ENABLE_SEARCH_DISCOVERY:-true} Subsonic__WaitForSearchDurations: ${WAIT_FOR_SEARCH_DURATIONS:-true} Subsonic__SearchDurationWaitMs: ${SEARCH_DURATION_WAIT_MS:-1000} Subsonic__SearchLastFmWaitMs: ${SEARCH_LASTFM_WAIT_MS:-1000} Subsonic__StreamSizeWarmPerMinute: ${STREAM_SIZE_WARM_PER_MINUTE:-5} # Clients that sync the library and search offline (Symfonium) get your radio # stations' tracks appended to the sync, so discovery reaches them too. Subsonic__EnableSyncCatalog: ${ENABLE_SYNC_CATALOG:-true} Subsonic__SyncCatalogClients: ${SYNC_CATALOG_CLIENTS:-Symfonium} Subsonic__SyncCatalogMaxSongs: ${SYNC_CATALOG_MAX_SONGS:-1000} # Where downloaded FLACs land (bind-mount below) Library__DownloadPath: /music # ----- yt-dlp shim sidecar (internal stream-preview source) ----- YouTube__ShimUrl: http://yt-dlp-shim:8080 # ----- slskd (Soulseek) sidecar ----- Soulseek__BaseUrl: http://slskd:5030 Soulseek__Username: ${SLSKD_USERNAME:-slskd} Soulseek__Password: ${SLSKD_PASSWORD:-slskd} # Where the dashboard's Open slskd button goes. Blank: this server's name # on port 5030. Soulseek__WebUrl: ${SLSKD_WEB_URL:-} # Ask Soulseek's port test whether other people can reach slskd (sends the # port number only, at most every 6 hours). Soulseek__CheckListenPort: ${SLSKD_CHECK_PORT:-true} # Share your library with the people you download from: where the # dashboard's Share my library switch starts. The installer and # .env.example turn it on; left unset it is off, so an update never # starts sharing on an existing install by itself. The switch wins once # flipped. SLSKD_SHARED_DIR is what is shared, as slskd sees it. Soulseek__ShareLibrary: ${SLSKD_SHARE_LIBRARY:-false} Soulseek__SharedFolders: ${SLSKD_SHARED_DIR:-[Music]/share} Soulseek__SearchWaitSeconds: ${SLSKD_SEARCH_WAIT_SECONDS:-30} Soulseek__UpgradeSearchWaitSeconds: ${SLSKD_UPGRADE_SEARCH_WAIT_SECONDS:-90} Soulseek__MinFileSizeBytes: ${SLSKD_MIN_FILE_SIZE_BYTES:-5242880} Soulseek__PreferredExtension: ${SLSKD_PREFERRED_EXTENSION:-flac} Soulseek__DownloadTimeoutSeconds: ${SLSKD_DOWNLOAD_TIMEOUT_SECONDS:-180} # Fingerprint every finished download and identify it before it joins the # library. A peer that sends a different recording gets that file remembered # and never tried again. Needs the free AcoustID key; inert without one. Soulseek__VerifyDownloads: ${SLSKD_VERIFY_DOWNLOADS:-false} Soulseek__AcoustIdApiKey: ${ACOUSTID_API_KEY:-} Soulseek__MinMatchScore: ${SLSKD_MIN_MATCH_SCORE:-85} Soulseek__TagFromMusicBrainz: ${SLSKD_TAG_FROM_MUSICBRAINZ:-false} # Name the file from the matched recording too (artist folder, title, album, # track). Off by default; only files Octo downloads and confirms are named this way. Soulseek__NameFromMatch: ${NAME_FROM_MATCH:-false} Soulseek__RejectedPeerTtlDays: ${SLSKD_REJECTED_PEER_DAYS:-30} Soulseek__FingerprintSeconds: ${SLSKD_FINGERPRINT_SECONDS:-120} Soulseek__FingerprintTimeoutSeconds: ${SLSKD_FINGERPRINT_TIMEOUT_SECONDS:-30} Soulseek__AcoustIdTimeoutSeconds: ${SLSKD_ACOUSTID_TIMEOUT_SECONDS:-10} # Hours a heart waits while slskd is up but not logged in to Soulseek (server # maintenance), before it uses the next source. 0 means do not wait. Soulseek__OutageHoldHours: ${SLSKD_OUTAGE_HOLD_HOURS:-6} # Downloads that may transfer at once (1 to 6). Each lands in its own folder, and Octo # keeps to one at a time until slskd has proven it. 1 is the old behaviour. Soulseek__ParallelDownloads: ${SLSKD_PARALLEL_DOWNLOADS:-3} # An album heart takes one peer's folder of the album in one batch, then searches song # by song only for what it lacks. false is the old song by song walk. Soulseek__AlbumFolders: ${SLSKD_ALBUM_FOLDERS:-true} # Send AcoustID the fingerprints you confirmed with Keep, so the next person gets a # confident match. Needs your personal AcoustID key (acoustid.org, after signing in). Soulseek__SubmitConfirmedFingerprints: ${ACOUSTID_SUBMIT:-false} Soulseek__AcoustIdUserApiKey: ${ACOUSTID_USER_KEY:-} # ----- existing Lidarr (optional heart-download source) ----- Lidarr__BaseUrl: ${LIDARR_URL:-} Lidarr__ApiKey: ${LIDARR_API_KEY:-} Lidarr__RootFolderPath: ${LIDARR_ROOT_FOLDER_PATH:-} Lidarr__QualityProfileId: ${LIDARR_QUALITY_PROFILE_ID:-0} Lidarr__MetadataProfileId: ${LIDARR_METADATA_PROFILE_ID:-0} Lidarr__CompletionMode: ${LIDARR_COMPLETION_MODE:-Accepted} Lidarr__ImportTimeoutSeconds: ${LIDARR_IMPORT_TIMEOUT_SECONDS:-1800} # ----- Last.fm radio (core Octo feature; no separate Radio service) ----- LastFm__ApiKey: ${LASTFM_API_KEY:-} LastFm__ApiSecret: ${LASTFM_API_SECRET:-} LastFm__EnableRadio: ${LASTFM_ENABLE_RADIO:-true} LastFm__RadioTrackCount: ${LASTFM_RADIO_TRACK_COUNT:-50} LastFm__RadioCacheDurationHours: ${LASTFM_RADIO_CACHE_HOURS:-24} LastFm__EnablePersonalizedStations: ${LASTFM_ENABLE_PERSONALIZED_STATIONS:-true} LastFm__EnableYourMix: ${LASTFM_ENABLE_YOUR_MIX:-true} LastFm__EnableDiscoveryMix: ${LASTFM_ENABLE_DISCOVERY_MIX:-true} LastFm__ArtistStationCount: ${LASTFM_ARTIST_STATION_COUNT:-2} LastFm__GenreStationCount: ${LASTFM_GENRE_STATION_COUNT:-3} LastFm__EnableDiscoveryStations: ${LASTFM_ENABLE_DISCOVERY_STATIONS:-true} LastFm__ExposeRadioAsPlaylists: ${LASTFM_EXPOSE_AS_PLAYLISTS:-true} LastFm__ExposeRadioAsStreams: ${LASTFM_EXPOSE_AS_STREAMS:-true} LastFm__RadioStreamBitrateKbps: ${LASTFM_RADIO_STREAM_BITRATE_KBPS:-192} LastFm__HistoryRetentionDays: ${LASTFM_HISTORY_RETENTION_DAYS:-90} LastFm__DiscoveryPercent: ${LASTFM_DISCOVERY_PERCENT:-35} LastFm__RefreshIntervalHours: ${LASTFM_REFRESH_INTERVAL_HOURS:-12} LastFm__MinimumPlays: ${LASTFM_MINIMUM_PLAYS:-10} # Radio's other sources (multi-source radio). Weights live on the dashboard's Radio page. RadioSources__YouTubeMusic: ${RADIO_YOUTUBE_MUSIC:-true} RadioSources__ListenBrainz: ${RADIO_LISTENBRAINZ:-true} RadioSources__ListenBrainzAlgorithm: ${RADIO_LISTENBRAINZ_ALGORITHM:-session_based_days_9000_session_300_contribution_5_threshold_15_limit_50_skip_30} RadioSources__ListenBrainzRadioMode: ${RADIO_LISTENBRAINZ_RADIO_MODE:-easy} RadioSources__SoundsAlike: ${RADIO_SOUNDS_ALIKE:-true} RadioSources__SonicUrl: ${RADIO_SONIC_URL:-http://octo-sonic:8080} RadioSources__SonicPauseSeconds: ${RADIO_SONIC_PAUSE_SECONDS:-2} RadioSources__LearnFromListening: ${RADIO_LEARN_FROM_LISTENING:-true} # ----- Popular right now: the chart of the moment as a playlist in every app ----- # Per listener, read-only, made again every 6 hours; on whether or not the mixes are. GeneratedPlaylists__PopularNow: ${POPULAR_NOW:-true} GeneratedPlaylists__Charts: ${CHARTS:-new,trending,18,14,6} GeneratedPlaylists__ChartCountry: ${CHART_COUNTRY:-auto} # ----- Mixes: genre and decade playlists drawn from your own library ----- # Served by Octo per listener, like the radio stations; nothing is written to Navidrome. GeneratedPlaylists__Enabled: ${MIXES_ENABLED:-false} GeneratedPlaylists__Genres: ${MIXES_GENRES:-true} GeneratedPlaylists__Decades: ${MIXES_DECADES:-true} GeneratedPlaylists__TrackCount: ${MIX_TRACK_COUNT:-100} GeneratedPlaylists__MaxPerArtist: ${MIX_MAX_PER_ARTIST:-3} GeneratedPlaylists__CreateAt: ${MIX_CREATE_AT:-20} GeneratedPlaylists__RemoveBelow: ${MIX_REMOVE_BELOW:-10} GeneratedPlaylists__MaxPlaylists: ${MIX_MAX_PLAYLISTS:-20} GeneratedPlaylists__RefreshHours: ${MIX_REFRESH_HOURS:-24} GeneratedPlaylists__NewShare: ${MIX_NEW_SHARE:-0} GeneratedPlaylists__NewDays: ${MIX_NEW_DAYS:-30} # Empty means "{0} Mix". A default with braces cannot be written here, because the # first closing brace would end the variable. GeneratedPlaylists__NameFormat: ${MIX_NAME_FORMAT:-} # A mix per country the artists are from, learned from MusicBrainz in the background. GeneratedPlaylists__CountryMixes: ${COUNTRY_MIXES:-false} GeneratedPlaylists__Countries: ${MIX_COUNTRIES:-} GeneratedPlaylists__MaxCountryPlaylists: ${MIX_MAX_COUNTRY_PLAYLISTS:-5} GeneratedPlaylists__CountryLookupsPerRefresh: ${COUNTRY_LOOKUPS_PER_REFRESH:-200} # ----- Made for you: New Releases, Rediscover, Deep Cuts ----- # Three lists per listener, on whether or not the mixes are, made again # every day from their own plays. New Releases asks Deezer (no key). GeneratedPlaylists__NewReleases: ${FOR_YOU_NEW_RELEASES:-true} GeneratedPlaylists__Rediscover: ${FOR_YOU_REDISCOVER:-true} GeneratedPlaylists__DeepCuts: ${FOR_YOU_DEEP_CUTS:-true} GeneratedPlaylists__NewReleaseWeeks: ${NEW_RELEASE_WEEKS:-8} GeneratedPlaylists__NewReleaseArtists: ${NEW_RELEASE_ARTISTS:-50} GeneratedPlaylists__RediscoverMonths: ${REDISCOVER_MONTHS:-6} # ----- Liked Songs: each listener's hearted songs, newest first, listed first ----- GeneratedPlaylists__LikedSongs: ${LIKED_SONGS:-true} GeneratedPlaylists__LikedSongsName: ${LIKED_SONGS_NAME:-Liked Songs} GeneratedPlaylists__LikedSongsMax: ${LIKED_SONGS_MAX:-500} # ----- Spotify import: lists from Spotify, a link or a file, and the trickle ----- Imports__SpotifyClientId: ${IMPORTS_SPOTIFY_CLIENT_ID:-} Imports__SpotifyRedirectUri: ${IMPORTS_SPOTIFY_REDIRECT_URI:-http://127.0.0.1/callback} Imports__SongsPerHour: ${IMPORTS_SONGS_PER_HOUR:-20} Imports__RefreshHours: ${IMPORTS_REFRESH_HOURS:-6} # ----- Genre normalization ----- # Collapses the genres downloads arrive with into a browsable list. Off by # default. The mapping table itself lives in the dashboard / settings.json, # because encoding structured rows in .env is brittle. Genre__Enabled: ${GENRE_NORMALIZE:-false} Genre__MaxGenres: ${GENRE_MAX:-10} Genre__OnEmpty: ${GENRE_ON_EMPTY:-Clear} Genre__Fallback: ${GENRE_FALLBACK:-None} Genre__UnknownLabel: ${GENRE_UNKNOWN_LABEL:-Unknown} Genre__BackfillMaxConsecutiveFailures: ${GENRE_BACKFILL_MAX_FAILURES:-25} # ----- Library actions (fix a wrong download from any client) ----- # Off by default: these actions remove files Octo did not create. The action # names, the star mapping and who may trigger them live in the dashboard. LibraryActions__Enabled: ${LIBRARY_ACTIONS_ENABLED:-false} LibraryActions__PlaylistsEnabled: ${LIBRARY_ACTIONS_PLAYLISTS:-true} LibraryActions__RatingsEnabled: ${LIBRARY_ACTIONS_RATINGS:-false} LibraryActions__DryRun: ${LIBRARY_ACTIONS_DRY_RUN:-true} LibraryActions__QuarantineDirectory: ${LIBRARY_ACTIONS_TRASH_DIR:-.octo-trash} LibraryActions__QuarantineRetentionDays: ${LIBRARY_ACTIONS_TRASH_DAYS:-30} LibraryActions__PollIntervalSeconds: ${LIBRARY_ACTIONS_POLL_SECONDS:-60} LibraryActions__MaxActionsPerCycle: ${LIBRARY_ACTIONS_MAX_PER_CYCLE:-20} LibraryActions__KeepReplacedOriginals: ${LIBRARY_ACTIONS_KEEP_REPLACED:-true} LibraryActions__PlaylistPrefix: ${LIBRARY_ACTIONS_PREFIX:-🛠 } # Octo's own questions: a Review playlist it fills with downloads AcoustID could not # settle. Answer with Keep (5 stars) or an action; removing a track answers too. LibraryActions__NoticePrefix: ${LIBRARY_ACTIONS_NOTICE_PREFIX:-▸ } LibraryActions__ReviewEnabled: ${LIBRARY_ACTIONS_REVIEW:-false} # Also check songs already in the library, this many an hour (0 is off). Only while nothing # downloads, only the library keeper is asked, and at most 50 such questions wait at once. LibraryActions__ReviewSweepPerHour: ${LIBRARY_ACTIONS_REVIEW_SWEEP_PER_HOUR:-0} LibraryActions__ReviewSweepOctoDownloads: ${LIBRARY_ACTIONS_REVIEW_SWEEP_OCTO_DOWNLOADS:-false} # Recordings the library holds more than once, side by side, the copy worth keeping first. # Octo only points them out; nothing is removed unless you remove it. LibraryActions__DuplicatesEnabled: ${LIBRARY_ACTIONS_DUPLICATES:-false} LibraryActions__DuplicatesScanHours: ${LIBRARY_ACTIONS_DUPLICATES_SCAN_HOURS:-24} LibraryActions__UpgradePerWeek: ${LIBRARY_ACTIONS_UPGRADE_PER_WEEK:-0} # Where Better quality looks: Auto (Soulseek, then Lidarr), Soulseek or Lidarr. LibraryActions__UpgradeSource: ${LIBRARY_ACTIONS_UPGRADE_SOURCE:-Auto} LibraryActions__NoticeMaxTracks: ${LIBRARY_ACTIONS_NOTICE_MAX:-100} # Auto: stars only act on tracks in Octo's playlists while Review or Duplicates is on. LibraryActions__RatingsScope: ${LIBRARY_ACTIONS_RATINGS_SCOPE:-Auto} # ----- Metadata language (genre tags etc. from Deezer / Last.fm) ----- Metadata__Language: ${METADATA_LANGUAGE:-en} # A download with no album anywhere is filed as a single under its title, instead # of joining Navidrome's one [Unknown Album]. Never applied to a compilation. Metadata__AlbumFromTitle: ${ALBUM_FROM_TITLE:-true} # Cover art: the Cover Art Archive for a fingerprinted release, then the catalog, # Deezer, iTunes and Last.fm. A non-square cover is a video frame and counts as # missing. cover.jpg goes beside a download only in a folder it created (Organized). Metadata__UseCoverArtArchive: ${COVER_ART_ARCHIVE:-true} Metadata__ReplaceVideoCovers: ${REPLACE_VIDEO_COVERS:-true} Metadata__WriteCoverFile: ${COVER_FILE:-true} # Embed covers at full size (often 3000 px) instead of 1500 px. cover.jpg is full size either way. Metadata__EmbedFullSizeCovers: ${FULL_SIZE_COVERS:-false} # Release matching: the original album and year for a song that arrived without an # album, release details from MusicBrainz, ReplayGain from a loudness measurement, # and a rehearsal mode that reports without writing the matched tags. Metadata__PreferOriginalAlbum: ${PREFER_ORIGINAL_ALBUM:-true} Metadata__YearFromOriginalRelease: ${YEAR_FROM_ORIGINAL_RELEASE:-true} Metadata__PreferredCountries: ${PREFERRED_COUNTRIES:-} Metadata__ReleaseDetailsLookup: ${RELEASE_DETAILS_LOOKUP:-true} Metadata__ReplayGain: ${REPLAYGAIN:-true} Metadata__ReplayGainTimeoutSeconds: ${REPLAYGAIN_TIMEOUT_SECONDS:-45} Metadata__TagRehearsal: ${TAG_REHEARSAL:-false} # Lyrics: a .lrc (synced) or .txt (plain) beside each download, and live lyrics # for songs as they play. kugou and netease are unofficial APIs; netease only # runs if listed, and leaving kugou out switches it off. Metadata__FetchLyrics: ${LYRICS_FETCH:-false} Metadata__LyricsSources: ${LYRICS_SOURCES:-song,kugou,lrclib,lyricsovh} Metadata__PreferWordTimedLyrics: ${LYRICS_PREFER_WORD_TIMED:-true} Metadata__WriteLyricsBesideAllSongs: ${LYRICS_WRITE_BESIDE_ALL:-false} Metadata__SaveLyricsTo: ${LYRICS_SAVE_TO:-beside} # ----- Notifications (ntfy / Discord webhook) — both optional ----- Notifications__NtfyUrl: ${NTFY_URL:-} Notifications__NtfyToken: ${NTFY_TOKEN:-} Notifications__DiscordWebhookUrl: ${DISCORD_WEBHOOK_URL:-} Notifications__NotifyDownloadStarted: ${NOTIFY_DOWNLOAD_STARTED:-false} Notifications__NotifyDownloadCompleted: ${NOTIFY_DOWNLOAD_COMPLETED:-true} Notifications__NotifyLosslessFallback: ${NOTIFY_LOSSLESS_FALLBACK:-true} Notifications__NotifyDownloadFailed: ${NOTIFY_DOWNLOAD_FAILED:-true} Notifications__NotifyAlbumCompleted: ${NOTIFY_ALBUM_COMPLETED:-true} # ----- Updates ----- # Ask GitHub every 6 hours whether a newer Octo release is out, and say so on # the dashboard. With the update helper on the host, the dashboard can also # install it (see "Updating" in the README). Updates__Check: ${UPDATES_CHECK:-true} Updates__Repo: ${UPDATES_REPO:-winters27/octo} # ----- Dashboard sign-in ----- # A Navidrome admin account signs in to the dashboard. off only behind a proxy # that signs people in itself. ADMIN_PORT gives the dashboard a port of its own. Admin__SignIn: ${ADMIN_SIGN_IN:-required} Admin__Port: ${ADMIN_PORT:-0} # ----- Family ----- # Accounts for the people you share your music with, each with a library of their # own. Their music is kept next to yours, in a .octo-family folder inside the music # folder, so it needs no volume of its own and stays out of your library. Off until # you turn it on here or on the dashboard. Family__Enabled: ${FAMILY_ENABLED:-false} # ----- Logging ----- # The two lines for every request and those for every call Octo makes to another # service are left out (Warning), so the log holds Octo's own lines and costs less # CPU. Information brings the request lines back, for example to watch what an app # asks for. Octo leaves them out by itself too; these keep the names older .env # files use. Logging__LogLevel__Microsoft.AspNetCore: ${LOG_LEVEL_ASPNETCORE:-Warning} Logging__LogLevel__System.Net.Http.HttpClient: ${LOG_LEVEL_HTTPCLIENT:-Warning} volumes: - ${DOWNLOAD_PATH:-./downloads}:/music # Only for keeping members' music somewhere other than next to your library: mount # that folder here and on navidrome at the same path, then choose "Somewhere else" # with that path when you turn Family on. # - ${MEMBERS_DIR:-./members}:/members # Editable settings file: the admin UI writes here, Octo reloads it on # change. Bind-mounted to host so values survive `docker compose up`. The # update helper watches its update/ folder. - ${OCTO_CONFIG_DIR:-./octo-config}:/app/config # Not octo-sonic: Octo runs without it, and waits for it on its own. depends_on: - yt-dlp-shim - slskd # Wraps yt-dlp behind /search and /stream HTTP endpoints. Isolating the binary # in its own container keeps its process-management quirks from wedging # the parent (see Brain/projects/Flaccy.md, 2026-04-29 LXC wedge note). # # Pulled, not built, like Octo, and at the same tag, so the two always match. # Without access to ghcr.io, build it from this folder with yt-dlp-shim-source (below). yt-dlp-shim: image: ghcr.io/winters27/octo-yt-dlp-shim:${OCTO_IMAGE_TAG:-2026.10.11} container_name: yt-dlp-shim restart: unless-stopped logging: *logging environment: # Threads serving requests. Each playing outside song holds one for the whole song, # so this is how many can play at once across the family. SHIM_THREADS: ${SHIM_THREADS:-128} MAX_CONCURRENT_YTDLP: ${YTDLP_MAX_CONCURRENT:-5} # Slots of the above that background work (prewarm, star-downloads) can # never occupy, so pressing play does not queue behind a prewarm burst. GATE_RESERVE_INTERACTIVE: ${YTDLP_GATE_RESERVE:-2} # Background gives up on a full gate quickly: it is fire-and-forget and # already stale by the time a long queue clears. BG_GATE_WAIT_SEC: ${YTDLP_BG_GATE_WAIT_SEC:-10} SEARCH_CACHE_MAX: ${YTDLP_SEARCH_CACHE_MAX:-1024} URL_CACHE_MAX: ${YTDLP_URL_CACHE_MAX:-512} URL_CACHE_TTL: ${YTDLP_URL_CACHE_TTL:-3600} # Root the /download endpoint may write under (matches the /music mount). DOWNLOAD_ROOT: /music # Run yt-dlp from the volume below rather than the copy baked into the # image, so a self-update survives the container being recreated. The # image copy is still the floor: a rebuild re-seeds this on next start. YTDLP_PATH: /opt/ytdlp/yt-dlp # Persistent duration cache (SQLite). On the named volume below so resolved # "artist - title -> {video_id, duration}" mappings survive container recreate. META_DB_PATH: /var/lib/ytshim/meta.db expose: - "8080" volumes: # Persist yt-dlp's player/nsig cache so base.js + signature solving is not # repeated on every fork, and survives container recreate. Named volume so # ownership is correct and `docker compose down` does not touch host paths. - ytdlp-cache:/root/.cache/yt-dlp # Persistent duration cache DB (see META_DB_PATH above). - ytshim-cache:/var/lib/ytshim # Shared music root so YouTube-MP3 downloads (DownloadSource=YouTube) land # where Navidrome scans; same host dir Octo and slskd mount, so dest paths # line up across containers. - ${DOWNLOAD_PATH:-./downloads}:/music # Writable home for the yt-dlp binary. YouTube rotates its player on its # own schedule and YTDLP_VERSION=latest is resolved at BUILD time, so an # image that is not rebuilt ships one frozen version forever. The shim # self-updates when a 403 outlives a cache purge; this is what makes that # update outlive the next `docker compose up -d`. - ytdlp-bin:/opt/ytdlp # Reads each song once: its transition profile, for the Octo apps' blends, and how it # sounds, for Sounds alike radio. Read-only on the music; each file is read by a child # process, so a damaged one costs only itself. Always started, so an update brings it with # no change to .env. RADIO_SOUNDS_ALIKE=false turns Sounds alike radio off: octo-sonic # still makes transition profiles, and no longer works out how songs sound. # An older .env with COMPOSE_PROFILES=sonic still works; the profile is simply not needed. # Pulled, not built, like the shim: compiling it (Rust and FFmpeg) takes minutes and more # memory than a small machine has. octo-sonic-source (below) builds it from this folder. octo-sonic: image: ghcr.io/winters27/octo-sonic:${OCTO_IMAGE_TAG:-2026.10.11} container_name: octo-sonic restart: unless-stopped logging: *logging # Passes docker stop's SIGTERM on and reaps the reader processes. init: true expose: - "8080" environment: MUSIC_ROOT: /music ANALYSE_CONCURRENCY: "1" SONIC_ENABLED: ${RADIO_SOUNDS_ALIKE:-true} # Docker refuses a cap above the machine's CPUs, and compose up then fails, so the # default is one. bliss uses about six threads a song. cpus: ${SONIC_CPUS:-1} # A long file is decoded whole in memory; a limit keeps one from crowding out the rest. mem_limit: 2g # It only reads the music, as nobody unless SONIC_USER says otherwise (a folder only its # owner can read: SONIC_USER=1000:1000). user: ${SONIC_USER:-65534:65534} read_only: true tmpfs: - /tmp cap_drop: [ALL] security_opt: - no-new-privileges:true volumes: - ${DOWNLOAD_PATH:-./downloads}:/music:ro # Soulseek client (slskd). Web UI on 5030, peer port 50300. # State (config, db, downloads-in-progress) persisted to ./slskd-state so the # account survives recreate. SLSKD_DOWNLOADS_DIR below sends finished files # to the same /music mount Octo watches. # # Peer port 50300 is the one other Soulseek users connect to, to download what # you share. Forward TCP 50300 on your router to this machine. Port 5030 is # slskd's own web page: keep it on your network, never forward it. slskd: image: slskd/slskd:latest container_name: slskd restart: unless-stopped logging: *logging ports: - "5030:5030" - "50300:50300" environment: # Lets slskd's administrator (Octo signs in as it) change slskd.yml through # slskd's API. The dashboard's Share my library switch needs it. SLSKD_REMOTE_CONFIGURATION: "true" # Completed downloads go to the shared /music mount so Octo can see and # relocate them. Without this slskd defaults to /downloads, which the # slskd-state mount turns into ./slskd-state/downloads, a directory Octo # does not mount (issue #17). Incomplete transfers stay at the default # /app/incomplete (inside slskd-state) so partial files never enter the # library Navidrome scans. # NOTE: an explicit directories.downloads in ./slskd-state/slskd.yml # outranks this env var (slskd precedence: env < yaml). SLSKD_DOWNLOADS_DIR: /music SLSKD_SLSK_USERNAME: ${SLSKD_SOULSEEK_USERNAME:-} SLSKD_SLSK_PASSWORD: ${SLSKD_SOULSEEK_PASSWORD:-} SLSKD_USERNAME: ${SLSKD_USERNAME:-slskd} SLSKD_PASSWORD: ${SLSKD_PASSWORD:-slskd} # ----- Sharing: give back to the people you download from ----- # Whether slskd shares your library is the Share my library switch on # the dashboard's Soulseek page (SLSKD_SHARE_LIBRARY sets where it starts). # Octo applies it by writing the share list into slskd.yml through slskd's # API, which is what remote configuration above allows, so do not set # SLSKD_SHARED_DIR here: slskd would share it whatever the switch says. # Shares come from the read-only /share mount below, so an upload can # never change a file. Octo's own working files are never shared: # partial and temporary copies, and its hidden folders (.octo-incoming, # .octo-trash), which # slskd skips anyway because their names start with a dot. SLSKD_SHARE_FILTER: '[\\/]\.octo-;\.octo-tmp$$;\.part$$;\.tmp$$' # Look through the shared folders again this often, in minutes (60 or # more), so songs added since are shared too. A library on a cloud drive # may want 10080, once a week: every look reads each file's header. SLSKD_SHARE_CACHE_RETENTION: ${SLSKD_SHARE_RESCAN_MINUTES:-1440} # Uploads at once, and their total speed in KiB/s, so sharing never # crowds out streaming from this server. Raise both if you have upload # to spare. SLSKD_UPLOAD_SLOTS: ${SLSKD_UPLOAD_SLOTS:-4} SLSKD_UPLOAD_SPEED_LIMIT: ${SLSKD_UPLOAD_SPEED_LIMIT:-2048} volumes: - ${SLSKD_STATE_DIR:-./slskd-state}:/app - ${DOWNLOAD_PATH:-./downloads}:/music # The same library again, read-only, for sharing. - ${DOWNLOAD_PATH:-./downloads}:/share:ro # The starter stack's music server, for anyone without a Navidrome of their own. # Off unless the navidrome profile is on: install.sh turns it on by writing # COMPOSE_PROFILES=navidrome to .env, or run `docker compose --profile navidrome up -d`. # It reads the same music folder Octo writes to, so a kept song shows up in the # library as soon as Octo asks for a scan. # Named navidrome-starter, not navidrome: a Navidrome of your own defined in an # override file as "navidrome" would otherwise merge with this one, take on its # profile, and leave nothing for Octo to start. Octo still reaches it as # http://navidrome:4533 through the alias below. navidrome-starter: image: deluan/navidrome:latest container_name: navidrome restart: unless-stopped logging: *logging profiles: ["navidrome"] networks: default: aliases: [navidrome] ports: # Navidrome's own pages: add the people who listen, change passwords, and # its web player. Music apps go to Octo on 5274, never here. - "${NAVIDROME_PORT:-4533}:4533" environment: ND_MUSICFOLDER: /music ND_DATAFOLDER: /data ND_LOGLEVEL: info # Creates the user "admin" with this password the first time Navidrome # starts, so there is no setup page to click through. Navidrome runs this # only once, on an empty data folder, and never changes an existing # password. Octo signs in with the same login (SUBSONIC_ADMIN_* above). ND_DEVAUTOCREATEADMINPASSWORD: ${NAVIDROME_ADMIN_PASSWORD:-} volumes: - ${DOWNLOAD_PATH:-./downloads}:/music # Only for keeping members' music somewhere other than next to your library: the # same folder octo mounts, at the same path. # - ${MEMBERS_DIR:-./members}:/members - ${NAVIDROME_DATA_DIR:-./navidrome-data}:/data # Octo and the sidecars built from this folder instead of pulled: for changes of your own, # or a machine that cannot reach ghcr.io. They never run; a build gives the image the name # the service above runs, so `docker compose up -d` then uses it: # docker compose --profile source build octo-source && docker compose up -d # A later `docker compose pull` puts the published image back. The update helper and # install.sh build them by themselves when a pull fails, and install.sh also when the # folder changed since its release. In a profile so a plain `docker compose build` builds # nothing, which is what an older update helper runs; that keeps even the update to this # release from compiling anything. octo-source: image: ghcr.io/winters27/octo:${OCTO_IMAGE_TAG:-2026.10.11} build: . profiles: ["source"] yt-dlp-shim-source: image: ghcr.io/winters27/octo-yt-dlp-shim:${OCTO_IMAGE_TAG:-2026.10.11} build: ./yt-dlp-shim profiles: ["source"] octo-sonic-source: image: ghcr.io/winters27/octo-sonic:${OCTO_IMAGE_TAG:-2026.10.11} build: ./sonic profiles: ["source"] volumes: ytdlp-cache: ytshim-cache: ytdlp-bin: