Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

New RSS module #715

Open
wants to merge 2 commits into
base: master
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 15 additions & 0 deletions lib/api/rss/models/rss_category.dart
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
import 'package:xml/xml.dart';

class RssCategory {
final String? domain;
final String value;

RssCategory(this.domain, this.value);

factory RssCategory.parse(XmlElement element) {
var domain = element.getAttribute('domain');
var value = element.text;

return RssCategory(domain, value);
}
}
53 changes: 53 additions & 0 deletions lib/api/rss/models/rss_feed.dart
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
import 'package:collection/collection.dart';
import 'package:xml/xml.dart';

import 'rss_item.dart';

class RssFeed {
final String? title;
final String? author;
final String? description;
final String? link;
final List<RssItem>? items;
List<RssItem>? filteredItems;
final String? lastBuildDate;

final DateTime? syncDate;

RssFeed({
this.title,
this.author,
this.description,
this.link,
this.items,
this.lastBuildDate,
this.filteredItems,
this.syncDate,
});

factory RssFeed.parse(String xmlString) {
var document = XmlDocument.parse(xmlString);
var rss = document.findElements('rss').firstOrNull;
var rdf = document.findElements('rdf:RDF').firstOrNull;
if (rss == null && rdf == null) {
throw ArgumentError('not a rss feed');
}
var channelElement = (rss ?? rdf)!.findElements('channel').firstOrNull;
if (channelElement == null) {
throw ArgumentError('channel not found');
}
return RssFeed(
title: channelElement.findElements('title').firstOrNull?.text,
author: channelElement.findElements('author').firstOrNull?.text,
description: channelElement.findElements('description').firstOrNull?.text,
link: channelElement.findElements('link').firstOrNull?.text,
items: (rdf ?? channelElement)
.findElements('item')
.map((e) => RssItem.parse(e))
.toList(),
lastBuildDate:
channelElement.findElements('lastBuildDate').firstOrNull?.text,
syncDate: DateTime.now().toUtc(),
);
}
}
9 changes: 9 additions & 0 deletions lib/api/rss/models/rss_feed_options.dart
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@

class RssFeedOptions {
final Map<String, String>? headers;

RssFeedOptions({
this.headers,
});

}
101 changes: 101 additions & 0 deletions lib/api/rss/models/rss_item.dart
Original file line number Diff line number Diff line change
@@ -0,0 +1,101 @@
import 'package:collection/collection.dart';
import 'package:intl/intl.dart';
import 'package:lunasea/system/logger.dart';
import 'package:xml/xml.dart';

import 'rss_category.dart';

const rfc822DatePattern = 'EEE, dd MMM yyyy HH:mm:ss Z';

class RssItem {
final Map<String, String> fields;
final String? title;
final String? description;
final String? link;

final List<RssCategory>? categories;
final String? guid;
final String? pubDate;
DateTime? get publishDate => _parseDateTime(pubDate);

bool isRecent;

RssItem({
required this.fields,
this.title,
this.description,
this.link,
this.categories,
this.guid,
this.pubDate,
this.isRecent = false,
});

factory RssItem.parse(XmlElement element) {
List<RssCategory>? categories = element
.findElements('category')
.map((e) => RssCategory.parse(e))
.toList();

Map<String, String> fields = {};
for (var v in element.childElements) {
if (!["title", "description", "link", "category", "guid", "pubdate"]
.contains(v.name.qualified.toLowerCase())) {
if (v.name.local == "category") {
categories.add(RssCategory(v.name.prefix, v.text));
}
fields[v.name.local] = v.text;
}
}

return RssItem(
fields: fields,
title: element.findElements('title').firstOrNull?.text,
description: element.findElements('description').firstOrNull?.text,
link: element.findElements('link').firstOrNull?.text,
categories: categories,
guid: element.findElements('guid').firstOrNull?.text,
pubDate: element.findElements('pubDate').firstOrNull?.text,
);
}

DateTime? _parseDateTime(dateString) {
if (dateString == null) return null;
// return _parseIso8601DateTime(dateString);
return _parseRfc822DateTime(dateString) ??
_parseIso8601DateTime(dateString);
}

DateTime? _parseRfc822DateTime(String dateString) {
try {
final format = DateFormat(rfc822DatePattern, 'en_US');
DateTime d = format.parse(dateString, true);

//DateFormat doesn't support timezones
final tz = RegExp(r'(\+|-)([0-9]{4})$');
final match = tz.firstMatch(dateString);
if (match != null) {
var duration = Duration(
hours: int.parse(match.group(2)!.substring(0, 2)),
minutes: int.parse(match.group(2)!.substring(2, 4)));
if (match.group(1) == '+') d = d.subtract(duration);
if (match.group(1) == '-') d = d.add(duration);
}
return d;
} on FormatException catch (error, stack) {
LunaLogger()
.error('RSS: Failed to parse Rfc822 date $dateString', error, stack);
return null;
}
}

DateTime? _parseIso8601DateTime(dateString) {
try {
return DateTime.parse(dateString);
} on FormatException catch (error, stack) {
LunaLogger()
.error('RSS: Failed to parse Iso8601 date $dateString', error, stack);
return null;
}
}
}
40 changes: 40 additions & 0 deletions lib/api/rss/rss.dart
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
import 'package:dio/dio.dart';
import 'package:lunasea/api/rss/models/rss_feed.dart';
import 'package:lunasea/api/rss/models/rss_feed_options.dart';

class RssAPI {
static const _USER_AGENT =
'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_4) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/13.1 Safari/605.1.15';
final Dio _dio;

RssAPI._internal(this._dio);

factory RssAPI.create() {
Dio _dio = Dio(
BaseOptions(
method: 'GET',
headers: {
'User-Agent': _USER_AGENT,
},
followRedirects: true,
maxRedirects: 5,
),
);
return RssAPI._internal(_dio);
}

Future<RssFeed> getFeedResult(String url, {RssFeedOptions? options}) async {
Response response =
await _dio.get(url, options: Options(headers: options!.headers));
if (response.statusCode! >= 400) {
throw Exception(response.statusMessage.toString());
}
RssFeed result = RssFeed.parse(response.data);

if (result.items!.isEmpty) {
throw Exception("Empty feed");
}

return result;
}
}
2 changes: 2 additions & 0 deletions lib/database/box.dart
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import 'package:lunasea/database/models/external_module.dart';
import 'package:lunasea/database/models/indexer.dart';
import 'package:lunasea/database/models/log.dart';
import 'package:lunasea/database/models/profile.dart';
import 'package:lunasea/database/models/rss.dart';
import 'package:lunasea/database/table.dart';
import 'package:lunasea/system/logger.dart';
import 'package:lunasea/vendor.dart';
Expand All @@ -12,6 +13,7 @@ enum LunaBox<T> {
alerts<dynamic>('alerts'),
externalModules<LunaExternalModule>('external_modules'),
indexers<LunaIndexer>('indexers'),
rss<LunaRss>('rss'),
logs<LunaLog>('logs'),
lunasea<dynamic>('lunasea'),
profiles<LunaProfile>('profiles');
Expand Down
11 changes: 11 additions & 0 deletions lib/database/config.dart
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import 'package:lunasea/core.dart';
import 'package:lunasea/database/database.dart';
import 'package:lunasea/database/models/external_module.dart';
import 'package:lunasea/database/models/indexer.dart';
import 'package:lunasea/database/models/rss.dart';
import 'package:lunasea/database/table.dart';

class LunaConfig {
Expand Down Expand Up @@ -36,6 +37,7 @@ class LunaConfig {
Map<String, dynamic> config = {};
config[LunaBox.externalModules.key] = LunaBox.externalModules.export();
config[LunaBox.indexers.key] = LunaBox.indexers.export();
config[LunaBox.rss.key] = LunaBox.rss.export();
config[LunaBox.profiles.key] = LunaBox.profiles.export();
for (final table in LunaTable.values) config[table.key] = table.export();

Expand All @@ -53,6 +55,15 @@ class LunaConfig {
}
}

void _setRss(List? data) {
if (data == null) return;

for (final rss in data) {
final obj = LunaRss.fromJson(rss);
LunaBox.rss.create(obj);
}
}

void _setIndexers(List? data) {
if (data == null) return;

Expand Down
100 changes: 100 additions & 0 deletions lib/database/models/rss.dart
Original file line number Diff line number Diff line change
@@ -0,0 +1,100 @@
import 'package:lunasea/api/rss/models/rss_feed.dart';
import 'package:lunasea/api/rss/models/rss_item.dart';
import 'package:lunasea/core.dart';

part 'rss.g.dart';

@JsonSerializable()
@HiveType(typeId: 30, adapterName: 'LunaRssAdapter')
class LunaRss extends HiveObject {
@JsonKey()
@HiveField(0, defaultValue: '')
String displayName;

@JsonKey()
@HiveField(1, defaultValue: '')
String url;

@JsonKey()
@HiveField(2, defaultValue: '')
String include;

@JsonKey()
@HiveField(3, defaultValue: '')
String exclude;

@JsonKey()
@HiveField(4, defaultValue: <String, String>{})
Map<String, String> headers;

@JsonKey()
@HiveField(5)
DateTime? syncDate;

@JsonKey()
int recent = 0;

@JsonKey()
DateTime? lastItemDate;

LunaRss._internal({
required this.displayName,
required this.url,
required this.include,
required this.exclude,
required this.headers,
});

factory LunaRss({
String? displayName,
String? url,
String? include,
String? exclude,
Map<String, String>? headers,
}) {
return LunaRss._internal(
displayName: displayName ?? '',
url: url ?? '',
include: include ?? '',
exclude: exclude ?? '',
headers: headers ?? {},
);
}

@override
String toString() => json.encode(this.toJson());

Map<String, dynamic> toJson() => _$LunaRssToJson(this);

factory LunaRss.fromJson(Map<String, dynamic> json) {
return _$LunaRssFromJson(json);
}

factory LunaRss.clone(LunaRss profile) {
return LunaRss.fromJson(profile.toJson());
}

factory LunaRss.get(String key) {
return LunaBox.rss.read(key)!;
}

void applyFilter(RssFeed value) {
int recent = 0;
List<RssItem> items = value.items!
.where((item) =>
this.include!.isEmpty ||
item.title!.contains(RegExp("(${this.include!})")))
.where((item) =>
this.exclude!.isEmpty ||
!item.title!.contains(RegExp("(${this.exclude!})")))
.onEach((item) {
item.isRecent =
this.syncDate == null || !this.syncDate!.isAfter(item.publishDate!);
if (item.isRecent) ++recent;
}).toList();

value.filteredItems = items;
this.recent = recent;
this.lastItemDate = value.filteredItems!.first!.publishDate;
}
}
4 changes: 3 additions & 1 deletion lib/database/table.dart
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ import 'package:lunasea/database/tables/sabnzbd.dart';
import 'package:lunasea/database/tables/search.dart';
import 'package:lunasea/database/tables/sonarr.dart';
import 'package:lunasea/database/tables/tautulli.dart';
import 'package:lunasea/database/tables/rss.dart';
import 'package:lunasea/vendor.dart';

enum LunaTable<T extends LunaTableMixin> {
Expand All @@ -25,7 +26,8 @@ enum LunaTable<T extends LunaTableMixin> {
sabnzbd<SABnzbdDatabase>('sabnzbd', items: SABnzbdDatabase.values),
search<SearchDatabase>('search', items: SearchDatabase.values),
sonarr<SonarrDatabase>('sonarr', items: SonarrDatabase.values),
tautulli<TautulliDatabase>('tautulli', items: TautulliDatabase.values);
tautulli<TautulliDatabase>('tautulli', items: TautulliDatabase.values),
rss<RssDatabase>('rss', items: RssDatabase.values);

final String key;
final List<T> items;
Expand Down
Loading