Skip to content

feat: Add ruTorrent discovery via Zeroconf/mDNS (#75)#198

Open
ananyaaa66 wants to merge 1 commit intoCCExtractor:mainfrom
ananyaaa66:feat/issue-75-zeroconf-discovery
Open

feat: Add ruTorrent discovery via Zeroconf/mDNS (#75)#198
ananyaaa66 wants to merge 1 commit intoCCExtractor:mainfrom
ananyaaa66:feat/issue-75-zeroconf-discovery

Conversation

@ananyaaa66
Copy link
Copy Markdown

@ananyaaa66 ananyaaa66 commented Mar 31, 2026

Closes #75

Summary

Adds a native mDNS/DNS-SD discovery feature to the login screen, allowing users to automatically find their ruTorrent servers on the local network without manual IP entry.

Changes

  • [NEW] discovery_service.dart: Implements a lightweight mDNS scanner using raw UDP multicasting to avoid new external dependencies. It scans for the _rutorrent_mobile._tcp.local service type.
    • login_view.dart: Added a 'Discover Servers' button that triggers the discovery process and displays results in a bottom sheet for easy selection and auto-filling of the URL.
    • login_viewmodel.dart: Provides the UI with reactive state from the discovery service.
    • app.dart & app.locator.dart: Registered the new DiscoveryService for dependency injection.
      This feature expects the ruTorrent server to be configured to advertise itself (e.g., via Avahi) using the _rutorrent_mobile._tcp service type.

Summary by CodeRabbit

  • New Features
    • Added local network server discovery functionality for ruTorrent services.
    • Introduced "Discover Servers on Network" button on the login screen that displays a modal with available servers.
    • Users can now automatically detect and select servers on their local network instead of manual entry.

- Create DiscoveryService using RawDatagramSocket for mDNS queries
- Implement _rutorrent_mobile._tcp service type discovery
- Add 'Discover Servers' button to LoginView
- Auto-fill URL from discovered server selection
- No external dependencies used for mDNS to ensure simple build process

Closes CCExtractor#75
@coderabbitai
Copy link
Copy Markdown

coderabbitai bot commented Mar 31, 2026

📝 Walkthrough

Walkthrough

This PR introduces local network discovery for ruTorrent servers using mDNS/DNS-SD protocol. A new DiscoveryService performs mDNS queries to find services on the network, and the login view integrates a discovery UI dialog allowing users to discover and select available servers.

Changes

Cohort / File(s) Summary
Dependency Registration
lib/app/app.dart, lib/app/app.locator.dart
Added DiscoveryService as a lazy singleton to the service locator and dependency container.
Discovery Service Implementation
lib/services/functional_services/discovery_service.dart
Introduced DiscoveredServer model and DiscoveryService class with mDNS-based discovery via UDP multicast, DNS response parsing (with name compression and SRV/TXT record extraction), timeout handling, and deduplication of discovered entries.
Login UI Integration
lib/ui/views/login/login_view.dart
Added "Discover Servers on Network" button and a modal bottom sheet dialog that displays discovery state and selectable server list via ValueListenableBuilder.
Login ViewModel
lib/ui/views/login/login_viewmodel.dart
Exposed discoveredServers and isDiscovering reactive getters and added discoverServers() async method to initiate discovery and handle empty-state feedback.

Sequence Diagram

sequenceDiagram
    participant User as User
    participant LoginView as Login View
    participant LoginVM as Login ViewModel
    participant DiscoveryService as Discovery Service
    participant Network as mDNS Network

    User->>LoginView: Tap "Discover Servers on Network"
    LoginView->>LoginVM: discoverServers()
    activate LoginVM
    LoginVM->>DiscoveryService: discover(timeout: 5s)
    activate DiscoveryService
    
    DiscoveryService->>Network: Bind UDP socket & join multicast group
    DiscoveryService->>Network: Send mDNS PTR query
    rect rgba(100, 150, 200, 0.5)
    note over Network: Listen for UDP responses until timeout
    Network-->>DiscoveryService: mDNS response packets
    DiscoveryService->>DiscoveryService: Parse DNS response, extract SRV/TXT records
    DiscoveryService->>DiscoveryService: Deduplicate by (host, port)
    end
    
    DiscoveryService-->>LoginVM: List<DiscoveredServer>
    deactivate DiscoveryService
    LoginVM->>LoginVM: Update discoveredServers ValueNotifier
    LoginVM-->>LoginView: notifyListeners()
    deactivate LoginVM
    
    LoginView->>LoginView: Render discovered servers in modal
    LoginView-->>User: Display list of available servers
    User->>LoginView: Select a server
Loading

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~50 minutes

Poem

🔍 Through mDNS whispers the rabbit does quest,
Finding servers hidden in the network's nest,
With DNS parsing and multicast flair,
Discovery is answered in the network air! 🎉

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Linked Issues check ⚠️ Warning The PR implements mDNS discovery for ruTorrent servers and integrates it into the login interface. However, the PR lacks documentation for Avahi XML sample configuration and TXT record specifications required by issue #75. Add documentation or sample files for Avahi XML configuration and document the TXT record format for protocol/URL metadata as outlined in issue #75.
✅ Passed checks (4 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title accurately describes the main change: adding ruTorrent discovery via Zeroconf/mDNS, which aligns with the primary objective of implementing service discovery.
Out of Scope Changes check ✅ Passed All changes are within scope: DiscoveryService implementation, dependency registration, UI integration, and ViewModel updates all directly support the mDNS discovery feature requested in issue #75.
Docstring Coverage ✅ Passed No functions found in the changed files to evaluate docstring coverage. Skipping docstring coverage check.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

Copy link
Copy Markdown

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 2

🧹 Nitpick comments (3)
lib/ui/views/login/login_view.dart (1)

50-58: Redundant setModalState(() {}) call before async operation.

The first setModalState(() {}) on line 53 doesn't change any state and serves no purpose. The rebuild will happen when the then callback invokes setModalState. However, since model.isDiscovering is a ValueNotifier and the UI uses ValueListenableBuilder, the UI should update automatically without needing setModalState at all.

♻️ Simplified refresh handler
                      IconButton(
                        icon: Icon(Icons.refresh),
                        onPressed: () {
-                         setModalState(() {});
-                         model.discoverServers().then((_) {
-                           setModalState(() {});
-                         });
+                         model.discoverServers();
                        },
                      ),
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@lib/ui/views/login/login_view.dart` around lines 50 - 58, Remove the
redundant immediate call to setModalState in the IconButton onPressed handler:
it does nothing before the async discover operation and the UI is already driven
by model.isDiscovering (a ValueNotifier) and ValueListenableBuilder. Update the
onPressed to simply call model.discoverServers() (or await/then the Future) and
remove the first setModalState(() {}) invocation so only the async call runs
(any necessary UI updates will come from model.discoverServers() updating
model.isDiscovering and the ValueListenableBuilder); reference: IconButton,
onPressed, model.discoverServers(), setModalState, model.isDiscovering,
ValueListenableBuilder.
lib/services/functional_services/discovery_service.dart (1)

176-178: Misleading comment: unicast-response bit is not actually set.

The comment says "Class: IN (1) with unicast-response bit" but the value 0x0001 is just the IN class without the unicast-response bit. The unicast-response bit would be 0x8001. The current value is fine for standard multicast mDNS queries—just the comment is inaccurate.

📝 Fix comment
    // Type: PTR (12)
    packet.addAll([0x00, 0x0C]);
-   // Class: IN (1) with unicast-response bit
+   // Class: IN (1)
    packet.addAll([0x00, 0x01]);
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@lib/services/functional_services/discovery_service.dart` around lines 176 -
178, The comment next to the DNS class bytes is incorrect: the bytes added by
packet.addAll([0x00, 0x01]) represent the IN class only and do not set the
unicast-response bit (that would be 0x8001); update the comment in
discovery_service.dart adjacent to the packet.addAll([0x00, 0x01]) call to
accurately say something like "Class: IN (1) — no unicast-response bit set
(unicast bit would be 0x8001)" so the code and comment match.
lib/ui/views/login/login_viewmodel.dart (1)

52-63: notifyListeners() is redundant here.

Since the UI uses ValueListenableBuilder bound directly to _discoveryService.discoveredServers and _discoveryService.isDiscovering, the UI updates automatically when those notifiers change. The notifyListeners() call from BaseViewModel doesn't contribute to the discovery UI updates.

♻️ Remove redundant call
  Future<List<DiscoveredServer>> discoverServers() async {
    log.i('Starting server discovery...');
    final servers = await _discoveryService.discover();
    if (servers.isEmpty) {
      Fluttertoast.showToast(
        msg: 'No servers found. Make sure your server advertises via mDNS.',
      );
    }
-   notifyListeners();
    return servers;
  }
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@lib/ui/views/login/login_viewmodel.dart` around lines 52 - 63, The
notifyListeners() call inside discoverServers() is redundant because the UI is
driven by ValueListenableBuilder listening to
_discoveryService.discoveredServers and _discoveryService.isDiscovering; remove
the notifyListeners() invocation from the Future<List<DiscoveredServer>>
discoverServers() method (leave the logging, discovery call, toast, and return
intact) so updates come solely from the discovery service notifiers and avoid
unnecessary BaseViewModel notifications.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@lib/services/functional_services/discovery_service.dart`:
- Around line 109-134: The socket.listen subscription in discover() (where
Timer(effectiveTimeout, ...), socket.listen(...), completer, and
discoveredServers are used) must be stored and explicitly cancelled to avoid
resource leaks and conflicting concurrent runs: create a StreamSubscription
variable for the listen call and call subscription.cancel() both inside the
timeout handler and when the completer completes (after socket.close()), and add
a simple concurrency guard (e.g., an _isDiscovering flag or checking an existing
non-completed completer) at the start of discover() to prevent starting a new
discovery while one is in progress; ensure you still close the socket and
complete the completer after cancelling the subscription and update
discoveredServers as before.
- Around line 185-261: _parseMdnsResponse currently returns a DiscoveredServer
for any DNS response; add a boolean (e.g., foundService) initialized false and
set it to true when you encounter a PTR (type 12) or SRV (type 33) record whose
service name matches "_rutorrent_mobile._tcp" (use _readName or the PTR target
text), and only construct/return DiscoveredServer if foundService is true
(otherwise return null). Also ensure you only apply SRV/TXT-derived
port/protocol values when foundService is true so unrelated packets don't
default to port 80; reference symbols: _parseMdnsResponse, _skipName, _readName,
type 12 (PTR), type 33 (SRV), and type 16 (TXT).

---

Nitpick comments:
In `@lib/services/functional_services/discovery_service.dart`:
- Around line 176-178: The comment next to the DNS class bytes is incorrect: the
bytes added by packet.addAll([0x00, 0x01]) represent the IN class only and do
not set the unicast-response bit (that would be 0x8001); update the comment in
discovery_service.dart adjacent to the packet.addAll([0x00, 0x01]) call to
accurately say something like "Class: IN (1) — no unicast-response bit set
(unicast bit would be 0x8001)" so the code and comment match.

In `@lib/ui/views/login/login_view.dart`:
- Around line 50-58: Remove the redundant immediate call to setModalState in the
IconButton onPressed handler: it does nothing before the async discover
operation and the UI is already driven by model.isDiscovering (a ValueNotifier)
and ValueListenableBuilder. Update the onPressed to simply call
model.discoverServers() (or await/then the Future) and remove the first
setModalState(() {}) invocation so only the async call runs (any necessary UI
updates will come from model.discoverServers() updating model.isDiscovering and
the ValueListenableBuilder); reference: IconButton, onPressed,
model.discoverServers(), setModalState, model.isDiscovering,
ValueListenableBuilder.

In `@lib/ui/views/login/login_viewmodel.dart`:
- Around line 52-63: The notifyListeners() call inside discoverServers() is
redundant because the UI is driven by ValueListenableBuilder listening to
_discoveryService.discoveredServers and _discoveryService.isDiscovering; remove
the notifyListeners() invocation from the Future<List<DiscoveredServer>>
discoverServers() method (leave the logging, discovery call, toast, and return
intact) so updates come solely from the discovery service notifiers and avoid
unnecessary BaseViewModel notifications.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: 289f6f09-861f-4ecb-8da9-2e81de288077

📥 Commits

Reviewing files that changed from the base of the PR and between 3fa8364 and 17f9f40.

📒 Files selected for processing (5)
  • lib/app/app.dart
  • lib/app/app.locator.dart
  • lib/services/functional_services/discovery_service.dart
  • lib/ui/views/login/login_view.dart
  • lib/ui/views/login/login_viewmodel.dart

Comment on lines +109 to +134
Timer(effectiveTimeout, () {
if (!completer.isCompleted) {
socket.close();
completer.complete(servers);
}
});

// Listen for responses
socket.listen((event) {
if (event == RawSocketEvent.read) {
final datagram = socket.receive();
if (datagram != null) {
try {
final server = _parseMdnsResponse(datagram);
if (server != null &&
!servers.any((s) => s.host == server.host && s.port == server.port)) {
servers.add(server);
_log.i('Discovered server: ${server.url}');
discoveredServers.value = List.from(servers);
}
} catch (e) {
_log.w('Error parsing mDNS response: $e');
}
}
}
});
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

Socket subscription not explicitly cancelled; potential resource leak on repeated calls.

The socket.listen() subscription is not stored or cancelled when the timeout fires. While socket.close() should eventually trigger the stream to end, explicitly cancelling the subscription is safer and clearer. Additionally, if discover() is called while a previous discovery is still in progress, both will run concurrently with conflicting state updates.

🛠️ Proposed fix: Store and cancel subscription, guard against concurrent calls
  Future<List<DiscoveredServer>> discover({Duration? timeout}) async {
    final effectiveTimeout = timeout ?? _discoveryTimeout;
+   
+   // Prevent concurrent discovery runs
+   if (isDiscovering.value) {
+     _log.w('Discovery already in progress');
+     return discoveredServers.value;
+   }
+   
    isDiscovering.value = true;
    discoveredServers.value = [];

And for the listener:

      // Listen for responses
-     socket.listen((event) {
+     final subscription = socket.listen((event) {
        if (event == RawSocketEvent.read) {
          // ... existing code ...
        }
      });

      // Set up timeout
      Timer(effectiveTimeout, () {
        if (!completer.isCompleted) {
+         subscription.cancel();
          socket.close();
          completer.complete(servers);
        }
      });
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@lib/services/functional_services/discovery_service.dart` around lines 109 -
134, The socket.listen subscription in discover() (where Timer(effectiveTimeout,
...), socket.listen(...), completer, and discoveredServers are used) must be
stored and explicitly cancelled to avoid resource leaks and conflicting
concurrent runs: create a StreamSubscription variable for the listen call and
call subscription.cancel() both inside the timeout handler and when the
completer completes (after socket.close()), and add a simple concurrency guard
(e.g., an _isDiscovering flag or checking an existing non-completed completer)
at the start of discover() to prevent starting a new discovery while one is in
progress; ensure you still close the socket and complete the completer after
cancelling the subscription and update discoveredServers as before.

Comment on lines +185 to +261
DiscoveredServer? _parseMdnsResponse(Datagram datagram) {
final data = datagram.data;

// Basic validation - minimum DNS header size
if (data.length < 12) return null;

// Check if this is a response (bit 15 of flags should be 1)
if ((data[2] & 0x80) == 0) return null;

// Use the sender's address as the host
final host = datagram.address.address;
int port = 80;
String protocol = 'http';
String? name;

// Try to extract SRV record (port) and TXT record (protocol)
// by scanning the response data
int offset = 12;
try {
// Skip questions section
final qdCount = (data[4] << 8) | data[5];
for (int i = 0; i < qdCount && offset < data.length; i++) {
offset = _skipName(data, offset);
offset += 4; // Skip QTYPE and QCLASS
}

// Parse answer/additional sections for SRV and TXT records
final anCount = (data[6] << 8) | data[7];
final nsCount = (data[8] << 8) | data[9];
final arCount = (data[10] << 8) | data[11];
final totalRecords = anCount + nsCount + arCount;

for (int i = 0; i < totalRecords && offset < data.length - 2; i++) {
offset = _skipName(data, offset);
if (offset + 10 > data.length) break;

final type = (data[offset] << 8) | data[offset + 1];
final rdLength = (data[offset + 8] << 8) | data[offset + 9];
offset += 10;

if (offset + rdLength > data.length) break;

if (type == 33 && rdLength >= 6) {
// SRV record
port = (data[offset + 4] << 8) | data[offset + 5];
} else if (type == 16) {
// TXT record
final txtData = String.fromCharCodes(
data.sublist(offset, offset + rdLength));
if (txtData.contains('protocol=https')) {
protocol = 'https';
} else if (txtData.contains('protocol=http')) {
protocol = 'http';
}
} else if (type == 12) {
// PTR record - might contain the service name
try {
name = _readName(data, offset);
} catch (_) {}
}

offset += rdLength;
}
} catch (e) {
_log.w('Error parsing DNS records: $e');
}

// Determine protocol from port if not specified in TXT
if (port == 443) protocol = 'https';

return DiscoveredServer(
host: host,
port: port,
protocol: protocol,
name: name,
);
}
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

Any mDNS response from the network is returned as a "discovered server", even if unrelated.

The method returns a DiscoveredServer for any packet with the response flag set, regardless of whether it actually advertises _rutorrent_mobile._tcp. If another device on the network responds to a different mDNS query around the same time, it could be incorrectly listed as a ruTorrent server with default port 80.

Consider validating that at least one record in the response matches the expected service type before returning a server.

🔧 Suggested approach

Add a flag to track whether a relevant record was found:

  DiscoveredServer? _parseMdnsResponse(Datagram datagram) {
    final data = datagram.data;

    // Basic validation - minimum DNS header size
    if (data.length < 12) return null;

    // Check if this is a response (bit 15 of flags should be 1)
    if ((data[2] & 0x80) == 0) return null;

    // Use the sender's address as the host
    final host = datagram.address.address;
    int port = 80;
    String protocol = 'http';
    String? name;
+   bool foundRelevantRecord = false;

    // Try to extract SRV record (port) and TXT record (protocol)
    // ... parsing logic ...
    
+       if (type == 33 && rdLength >= 6) {
+         // SRV record
+         port = (data[offset + 4] << 8) | data[offset + 5];
+         foundRelevantRecord = true;
+       }
    // ... rest of parsing ...

+   if (!foundRelevantRecord) return null;
+
    // Determine protocol from port if not specified in TXT
    if (port == 443) protocol = 'https';
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@lib/services/functional_services/discovery_service.dart` around lines 185 -
261, _parseMdnsResponse currently returns a DiscoveredServer for any DNS
response; add a boolean (e.g., foundService) initialized false and set it to
true when you encounter a PTR (type 12) or SRV (type 33) record whose service
name matches "_rutorrent_mobile._tcp" (use _readName or the PTR target text),
and only construct/return DiscoveredServer if foundService is true (otherwise
return null). Also ensure you only apply SRV/TXT-derived port/protocol values
when foundService is true so unrelated packets don't default to port 80;
reference symbols: _parseMdnsResponse, _skipName, _readName, type 12 (PTR), type
33 (SRV), and type 16 (TXT).

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Add rutorrent discovery?

1 participant