Newznab Responses for Caps and Movie Search (rough)

pull/2/head
Qstick 4 years ago
parent 84cbfe870f
commit cfb1a80c58

@ -0,0 +1,55 @@
import PropTypes from 'prop-types';
import React, { Component } from 'react';
import { connect } from 'react-redux';
import { createSelector } from 'reselect';
import EnhancedSelectInput from './EnhancedSelectInput';
function createMapStateToProps() {
return createSelector(
(state, { value }) => value,
(state) => state.indexers,
(value, indexers) => {
const values = indexers.items.map(({ id, name }) => {
return {
key: id,
value: name
};
});
return {
value,
values
};
}
);
}
class IndexersSelectInputConnector extends Component {
onChange = ({ name, value }) => {
this.props.onChange({ name, value });
}
//
// Render
render() {
return (
<EnhancedSelectInput
{...this.props}
onChange={this.onChange}
/>
);
}
}
IndexersSelectInputConnector.propTypes = {
name: PropTypes.string.isRequired,
indexerIds: PropTypes.number.isRequired,
value: PropTypes.arrayOf(PropTypes.number).isRequired,
values: PropTypes.arrayOf(PropTypes.object).isRequired,
onChange: PropTypes.func.isRequired
};
export default connect(createMapStateToProps)(IndexersSelectInputConnector);

@ -26,34 +26,16 @@ function MovieIndexSortMenu(props) {
sortDirection={sortDirection}
onPress={onSortSelect}
>
Monitored/Status
Status
</SortMenuItem>
<SortMenuItem
name="sortTitle"
name="name"
sortKey={sortKey}
sortDirection={sortDirection}
onPress={onSortSelect}
>
{translate('Title')}
</SortMenuItem>
<SortMenuItem
name="studio"
sortKey={sortKey}
sortDirection={sortDirection}
onPress={onSortSelect}
>
{translate('Studio')}
</SortMenuItem>
<SortMenuItem
name="qualityProfileId"
sortKey={sortKey}
sortDirection={sortDirection}
onPress={onSortSelect}
>
{translate('QualityProfile')}
{translate('Name')}
</SortMenuItem>
<SortMenuItem
@ -66,66 +48,21 @@ function MovieIndexSortMenu(props) {
</SortMenuItem>
<SortMenuItem
name="year"
sortKey={sortKey}
sortDirection={sortDirection}
onPress={onSortSelect}
>
{translate('Year')}
</SortMenuItem>
<SortMenuItem
name="inCinemas"
sortKey={sortKey}
sortDirection={sortDirection}
onPress={onSortSelect}
>
{translate('InCinemas')}
</SortMenuItem>
<SortMenuItem
name="physicalRelease"
sortKey={sortKey}
sortDirection={sortDirection}
onPress={onSortSelect}
>
{translate('PhysicalRelease')}
</SortMenuItem>
<SortMenuItem
name="digitalRelease"
sortKey={sortKey}
sortDirection={sortDirection}
onPress={onSortSelect}
>
{translate('DigitalRelease')}
</SortMenuItem>
<SortMenuItem
name="path"
sortKey={sortKey}
sortDirection={sortDirection}
onPress={onSortSelect}
>
{translate('Path')}
</SortMenuItem>
<SortMenuItem
name="sizeOnDisk"
name="protocol"
sortKey={sortKey}
sortDirection={sortDirection}
onPress={onSortSelect}
>
{translate('SizeOnDisk')}
{'Protocol'}
</SortMenuItem>
<SortMenuItem
name="certification"
name="privacy"
sortKey={sortKey}
sortDirection={sortDirection}
onPress={onSortSelect}
>
{translate('Certification')}
{'Privacy'}
</SortMenuItem>
</MenuContent>
</SortMenu>

@ -4,16 +4,16 @@ import Label from 'Components/Label';
function CapabilitiesLabel(props) {
const {
supportsBooks,
supportsMovies,
supportsMusic,
supportsTv
} = props;
movieSearchAvailable,
tvSearchAvailable,
musicSearchAvailable,
bookSearchAvailable
} = props.capabilities;
return (
<span>
{
supportsBooks ?
bookSearchAvailable ?
<Label>
{'Books'}
</Label> :
@ -21,7 +21,7 @@ function CapabilitiesLabel(props) {
}
{
supportsMovies ?
movieSearchAvailable ?
<Label>
{'Movies'}
</Label> :
@ -29,7 +29,7 @@ function CapabilitiesLabel(props) {
}
{
supportsMusic ?
musicSearchAvailable ?
<Label>
{'Music'}
</Label> :
@ -37,7 +37,7 @@ function CapabilitiesLabel(props) {
}
{
supportsTv ?
tvSearchAvailable ?
<Label>
{'TV'}
</Label> :
@ -45,7 +45,7 @@ function CapabilitiesLabel(props) {
}
{
!supportsTv && !supportsMusic && !supportsMovies && !supportsBooks ?
!tvSearchAvailable && !musicSearchAvailable && !movieSearchAvailable && !bookSearchAvailable ?
<Label>
{'None'}
</Label> :
@ -56,10 +56,7 @@ function CapabilitiesLabel(props) {
}
CapabilitiesLabel.propTypes = {
supportsTv: PropTypes.bool.isRequired,
supportsBooks: PropTypes.bool.isRequired,
supportsMusic: PropTypes.bool.isRequired,
supportsMovies: PropTypes.bool.isRequired
capabilities: PropTypes.object.isRequired
};
export default CapabilitiesLabel;

@ -64,10 +64,7 @@ class MovieIndexRow extends Component {
protocol,
privacy,
added,
supportsTv,
supportsBooks,
supportsMusic,
supportsMovies,
capabilities,
columns,
isMovieEditorActive,
isSelected,
@ -175,10 +172,7 @@ class MovieIndexRow extends Component {
className={styles[column.name]}
>
<CapabilitiesLabel
supportsBooks={supportsBooks}
supportsMovies={supportsMovies}
supportsMusic={supportsMusic}
supportsTv={supportsTv}
capabilities={capabilities}
/>
</VirtualTableRowCell>
);
@ -243,10 +237,7 @@ MovieIndexRow.propTypes = {
enableRss: PropTypes.bool.isRequired,
enableAutomaticSearch: PropTypes.bool.isRequired,
enableInteractiveSearch: PropTypes.bool.isRequired,
supportsTv: PropTypes.bool.isRequired,
supportsBooks: PropTypes.bool.isRequired,
supportsMusic: PropTypes.bool.isRequired,
supportsMovies: PropTypes.bool.isRequired,
capabilities: PropTypes.object.isRequired,
added: PropTypes.string.isRequired,
tags: PropTypes.arrayOf(PropTypes.number).isRequired,
columns: PropTypes.arrayOf(PropTypes.object).isRequired,

@ -0,0 +1,6 @@
.message {
margin-top: 10px;
margin-bottom: 30px;
text-align: center;
font-size: 20px;
}

@ -0,0 +1,32 @@
import PropTypes from 'prop-types';
import React from 'react';
import translate from 'Utilities/String/translate';
import styles from './NoSearchResults.css';
function NoSearchResults(props) {
const { totalItems } = props;
if (totalItems > 0) {
return (
<div>
<div className={styles.message}>
{translate('AllIndexersHiddenDueToFilter')}
</div>
</div>
);
}
return (
<div>
<div className={styles.message}>
No search results found, try performing a new search below.
</div>
</div>
);
}
NoSearchResults.propTypes = {
totalItems: PropTypes.number.isRequired
};
export default NoSearchResults;

@ -3,6 +3,11 @@
min-width: 150px;
}
.indexerContainer {
margin-right: 20px;
min-width: 250px;
}
.buttonContainer {
display: flex;
justify-content: flex-end;

@ -1,5 +1,6 @@
import PropTypes from 'prop-types';
import React, { Component } from 'react';
import IndexersSelectInputConnector from 'Components/Form/IndexersSelectInputConnector';
import TextInput from 'Components/Form/TextInput';
import SpinnerButton from 'Components/Link/SpinnerButton';
import PageContentFooter from 'Components/Page/PageContentFooter';
@ -15,7 +16,8 @@ class SearchFooter extends Component {
this.state = {
searchingReleases: false,
searchQuery: ''
searchQuery: '',
indexerIds: []
};
}
@ -40,7 +42,7 @@ class SearchFooter extends Component {
}
onSearchPress = () => {
this.props.onSearchPress(this.state.searchQuery);
this.props.onSearchPress(this.state.searchQuery, this.state.indexerIds);
}
//
@ -52,7 +54,8 @@ class SearchFooter extends Component {
} = this.props;
const {
searchQuery
searchQuery,
indexerIds
} = this.state;
return (
@ -67,6 +70,16 @@ class SearchFooter extends Component {
/>
</div>
<div className={styles.indexerContainer}>
<IndexersSelectInputConnector
name='indexerIds'
placeholder='Indexers'
value={indexerIds}
isDisabled={isFetching}
onChange={this.onInputChange}
/>
</div>
<div className={styles.buttonContainer}>
<div className={styles.buttonContainerContent}>
<div className={styles.buttons}>

@ -11,13 +11,13 @@ import PageToolbarSection from 'Components/Page/Toolbar/PageToolbarSection';
import PageToolbarSeparator from 'Components/Page/Toolbar/PageToolbarSeparator';
import TableOptionsModalWrapper from 'Components/Table/TableOptions/TableOptionsModalWrapper';
import { align, icons, sortDirections } from 'Helpers/Props';
import NoIndexer from 'Indexer/NoIndexer';
import * as keyCodes from 'Utilities/Constants/keyCodes';
import getErrorMessage from 'Utilities/Object/getErrorMessage';
import hasDifferentItemsOrOrder from 'Utilities/Object/hasDifferentItemsOrOrder';
import translate from 'Utilities/String/translate';
import SearchIndexFilterMenu from './Menus/SearchIndexFilterMenu';
import SearchIndexSortMenu from './Menus/SearchIndexSortMenu';
import NoSearchResults from './NoSearchResults';
import SearchFooter from './SearchFooter.js';
import SearchIndexTableConnector from './Table/SearchIndexTableConnector';
import styles from './SearchIndex.css';
@ -126,9 +126,8 @@ class SearchIndex extends Component {
this.setState({ jumpToCharacter });
}
onSearchPress = (query) => {
console.log('index', query);
this.props.onSearchPress({ query });
onSearchPress = (query, indexerIds) => {
this.props.onSearchPress({ query, indexerIds });
}
onKeyUp = (event) => {
@ -175,6 +174,8 @@ class SearchIndex extends Component {
const isLoaded = !!(!error && isPopulated && items.length && scroller);
const hasNoIndexer = !totalItems;
console.log(hasNoIndexer);
return (
<PageContent>
<PageToolbar>
@ -246,8 +247,8 @@ class SearchIndex extends Component {
}
{
!error && isPopulated && !items.length &&
<NoIndexer totalItems={totalItems} />
!error && !isFetching && !items.length &&
<NoSearchResults totalItems={totalItems} />
}
</PageContentBody>

@ -95,25 +95,7 @@ export const defaultState = {
],
sortPredicates: {
...sortPredicates,
studio: function(item) {
const studio = item.studio;
return studio ? studio.toLowerCase() : '';
},
collection: function(item) {
const { collection ={} } = item;
return collection.name;
},
ratings: function(item) {
const { ratings = {} } = item;
return ratings.value;
}
...sortPredicates
},
selectedFilterKey: 'all',
@ -122,12 +104,6 @@ export const defaultState = {
filterPredicates,
filterBuilderProps: [
{
name: 'monitored',
label: translate('Monitored'),
type: filterBuilderTypes.EXACT,
valueType: filterBuilderValueTypes.BOOL
},
{
name: 'title',
label: 'Indexer Name',

@ -1,11 +1,10 @@
import _ from 'lodash';
import { createAction } from 'redux-actions';
import { filterTypePredicates, filterTypes, sortDirections } from 'Helpers/Props';
import { sortDirections } from 'Helpers/Props';
import { createThunk, handleThunks } from 'Store/thunks';
// import { batchActions } from 'redux-batched-actions';
import createAjaxRequest from 'Utilities/createAjaxRequest';
import dateFilterPredicate from 'Utilities/Date/dateFilterPredicate';
import padNumber from 'Utilities/Number/padNumber';
import translate from 'Utilities/String/translate';
import { updateItem } from './baseActions';
import createFetchHandler from './Creators/createFetchHandler';
@ -24,123 +23,12 @@ export const filters = [
key: 'all',
label: translate('All'),
filters: []
},
{
key: 'monitored',
label: translate('MonitoredOnly'),
filters: [
{
key: 'monitored',
value: true,
type: filterTypes.EQUAL
}
]
},
{
key: 'unmonitored',
label: translate('Unmonitored'),
filters: [
{
key: 'monitored',
value: false,
type: filterTypes.EQUAL
}
]
},
{
key: 'missing',
label: translate('Missing'),
filters: [
{
key: 'monitored',
value: true,
type: filterTypes.EQUAL
},
{
key: 'hasFile',
value: false,
type: filterTypes.EQUAL
}
]
},
{
key: 'wanted',
label: translate('Wanted'),
filters: [
{
key: 'monitored',
value: true,
type: filterTypes.EQUAL
},
{
key: 'hasFile',
value: false,
type: filterTypes.EQUAL
},
{
key: 'isAvailable',
value: true,
type: filterTypes.EQUAL
}
]
},
{
key: 'cutoffunmet',
label: translate('CutoffUnmet'),
filters: [
{
key: 'monitored',
value: true,
type: filterTypes.EQUAL
},
{
key: 'hasFile',
value: true,
type: filterTypes.EQUAL
},
{
key: 'qualityCutoffNotMet',
value: true,
type: filterTypes.EQUAL
}
]
}
];
export const filterPredicates = {
added: function(item, filterValue, type) {
return dateFilterPredicate(item.added, filterValue, type);
},
collection: function(item, filterValue, type) {
const predicate = filterTypePredicates[type];
const { collection } = item;
return predicate(collection ? collection.name : '', filterValue);
},
inCinemas: function(item, filterValue, type) {
return dateFilterPredicate(item.inCinemas, filterValue, type);
},
physicalRelease: function(item, filterValue, type) {
return dateFilterPredicate(item.physicalRelease, filterValue, type);
},
digitalRelease: function(item, filterValue, type) {
return dateFilterPredicate(item.digitalRelease, filterValue, type);
},
ratings: function(item, filterValue, type) {
const predicate = filterTypePredicates[type];
return predicate(item.ratings.value * 10, filterValue);
},
qualityCutoffNotMet: function(item) {
const { movieFile = {} } = item;
return movieFile.qualityCutoffNotMet;
}
};
@ -165,33 +53,6 @@ export const sortPredicates = {
}
return result;
},
movieStatus: function(item) {
let result = 0;
let qualityName = '';
const hasMovieFile = !!item.movieFile;
if (item.isAvailable) {
result++;
}
if (item.monitored) {
result += 2;
}
if (hasMovieFile) {
// TODO: Consider Quality Weight for Sorting within status of hasMovie
if (item.movieFile.qualityCutoffNotMet) {
result += 4;
} else {
result += 8;
}
qualityName = item.movieFile.quality.quality.name;
}
return padNumber(result.toString(), 2) + qualityName;
}
};
@ -205,7 +66,7 @@ export const defaultState = {
isSaving: false,
saveError: null,
items: [],
sortKey: 'sortTitle',
sortKey: 'name',
sortDirection: sortDirections.ASCENDING,
pendingChanges: {}
};

@ -1,74 +0,0 @@
import { batchActions } from 'redux-batched-actions';
import { createThunk, handleThunks } from 'Store/thunks';
import createAjaxRequest from 'Utilities/createAjaxRequest';
import { set, update } from './baseActions';
import createHandleActions from './Creators/createHandleActions';
//
// Variables
export const section = 'movieTitles';
//
// State
export const defaultState = {
isFetching: false,
isPopulated: false,
error: null,
items: []
};
//
// Actions Types
export const FETCH_MOVIE_TITLES = 'movieTitles/fetchMovieTitles';
//
// Action Creators
export const fetchMovieTitles = createThunk(FETCH_MOVIE_TITLES);
//
// Action Handlers
export const actionHandlers = handleThunks({
[FETCH_MOVIE_TITLES]: function(getState, payload, dispatch) {
dispatch(set({ section, isFetching: true }));
const promise = createAjaxRequest({
url: '/alttitle',
data: payload
}).request;
promise.done((data) => {
dispatch(batchActions([
update({ section, data }),
set({
section,
isFetching: false,
isPopulated: true,
error: null
})
]));
});
promise.fail((xhr) => {
dispatch(set({
section,
isFetching: false,
isPopulated: false,
error: xhr
}));
});
}
});
//
// Reducers
export const reducers = createHandleActions({
}, defaultState, section);

@ -1,4 +1,4 @@
using System;
using System;
using System.Xml;
using FluentAssertions;
using Moq;
@ -6,7 +6,6 @@ using NUnit.Framework;
using NzbDrone.Common.Http;
using NzbDrone.Core.Indexers.Newznab;
using NzbDrone.Core.Test.Framework;
using NzbDrone.Test.Common;
namespace NzbDrone.Core.Test.IndexerTests.NewznabTests
{
@ -53,8 +52,8 @@ namespace NzbDrone.Core.Test.IndexerTests.NewznabTests
var caps = Subject.GetCapabilities(_settings);
caps.DefaultPageSize.Should().Be(25);
caps.MaxPageSize.Should().Be(60);
caps.LimitsDefault.Value.Should().Be(25);
caps.LimitsMax.Value.Should().Be(60);
}
[Test]
@ -64,8 +63,8 @@ namespace NzbDrone.Core.Test.IndexerTests.NewznabTests
var caps = Subject.GetCapabilities(_settings);
caps.DefaultPageSize.Should().Be(100);
caps.MaxPageSize.Should().Be(100);
caps.LimitsDefault.Value.Should().Be(100);
caps.LimitsMax.Value.Should().Be(100);
}
[Test]

@ -1,4 +1,4 @@
using System;
using System;
using System.Linq;
using FluentAssertions;
using Moq;
@ -13,7 +13,7 @@ namespace NzbDrone.Core.Test.IndexerTests.NewznabTests
[TestFixture]
public class NewznabFixture : CoreTest<Newznab>
{
private NewznabCapabilities _caps;
private IndexerCapabilities _caps;
[SetUp]
public void Setup()
@ -29,7 +29,7 @@ namespace NzbDrone.Core.Test.IndexerTests.NewznabTests
}
};
_caps = new NewznabCapabilities();
_caps = new IndexerCapabilities();
Mocker.GetMock<INewznabCapabilitiesProvider>()
.Setup(v => v.GetCapabilities(It.IsAny<NewznabSettings>()))
.Returns(_caps);
@ -64,8 +64,8 @@ namespace NzbDrone.Core.Test.IndexerTests.NewznabTests
[Test]
public void should_use_pagesize_reported_by_caps()
{
_caps.MaxPageSize = 30;
_caps.DefaultPageSize = 25;
_caps.LimitsMax = 30;
_caps.LimitsDefault = 25;
Subject.PageSize.Should().Be(25);
}

@ -3,6 +3,7 @@ using System.Linq;
using FluentAssertions;
using Moq;
using NUnit.Framework;
using NzbDrone.Core.Indexers;
using NzbDrone.Core.Indexers.Newznab;
using NzbDrone.Core.IndexerSearch.Definitions;
using NzbDrone.Core.Test.Framework;
@ -12,7 +13,7 @@ namespace NzbDrone.Core.Test.IndexerTests.NewznabTests
public class NewznabRequestGeneratorFixture : CoreTest<NewznabRequestGenerator>
{
private MovieSearchCriteria _movieSearchCriteria;
private NewznabCapabilities _capabilities;
private IndexerCapabilities _capabilities;
[SetUp]
public void SetUp()
@ -29,7 +30,7 @@ namespace NzbDrone.Core.Test.IndexerTests.NewznabTests
SceneTitles = new List<string> { "Star Wars" }
};
_capabilities = new NewznabCapabilities();
_capabilities = new IndexerCapabilities();
Mocker.GetMock<INewznabCapabilitiesProvider>()
.Setup(v => v.GetCapabilities(It.IsAny<NewznabSettings>()))
@ -91,7 +92,7 @@ namespace NzbDrone.Core.Test.IndexerTests.NewznabTests
[Test]
public void should_not_search_by_imdbid_if_not_supported()
{
_capabilities.SupportedMovieSearchParameters = new[] { "q" };
_capabilities.MovieSearchParams = new List<MovieSearchParam> { MovieSearchParam.Q };
var results = Subject.GetSearchRequests(_movieSearchCriteria);
@ -106,7 +107,7 @@ namespace NzbDrone.Core.Test.IndexerTests.NewznabTests
[Test]
public void should_search_by_imdbid_if_supported()
{
_capabilities.SupportedMovieSearchParameters = new[] { "q", "imdbid" };
_capabilities.MovieSearchParams = new List<MovieSearchParam> { MovieSearchParam.Q, MovieSearchParam.ImdbId };
var results = Subject.GetSearchRequests(_movieSearchCriteria);
results.GetTier(0).Should().HaveCount(1);
@ -119,7 +120,7 @@ namespace NzbDrone.Core.Test.IndexerTests.NewznabTests
[Test]
public void should_search_by_tmdbid_if_supported()
{
_capabilities.SupportedMovieSearchParameters = new[] { "q", "tmdbid" };
_capabilities.MovieSearchParams = new List<MovieSearchParam> { MovieSearchParam.Q, MovieSearchParam.TmdbId };
var results = Subject.GetSearchRequests(_movieSearchCriteria);
results.GetTier(0).Should().HaveCount(1);
@ -132,7 +133,7 @@ namespace NzbDrone.Core.Test.IndexerTests.NewznabTests
[Test]
public void should_prefer_search_by_tmdbid_if_rid_supported()
{
_capabilities.SupportedMovieSearchParameters = new[] { "q", "tmdbid", "imdbid" };
_capabilities.MovieSearchParams = new List<MovieSearchParam> { MovieSearchParam.Q, MovieSearchParam.ImdbId, MovieSearchParam.TmdbId };
var results = Subject.GetSearchRequests(_movieSearchCriteria);
results.GetTier(0).Should().HaveCount(1);
@ -146,8 +147,7 @@ namespace NzbDrone.Core.Test.IndexerTests.NewznabTests
[Test]
public void should_use_aggregrated_id_search_if_supported()
{
_capabilities.SupportedMovieSearchParameters = new[] { "q", "tmdbid", "imdbid" };
_capabilities.SupportsAggregateIdSearch = true;
_capabilities.MovieSearchParams = new List<MovieSearchParam> { MovieSearchParam.Q, MovieSearchParam.ImdbId, MovieSearchParam.TmdbId };
var results = Subject.GetSearchRequests(_movieSearchCriteria);
results.GetTier(0).Should().HaveCount(1);
@ -161,8 +161,7 @@ namespace NzbDrone.Core.Test.IndexerTests.NewznabTests
[Test]
public void should_not_use_aggregrated_id_search_if_no_ids_supported()
{
_capabilities.SupportedMovieSearchParameters = new[] { "q" };
_capabilities.SupportsAggregateIdSearch = true; // Turns true if indexer supplies supportedParams.
_capabilities.MovieSearchParams = new List<MovieSearchParam> { MovieSearchParam.Q };
var results = Subject.GetSearchRequests(_movieSearchCriteria);
results.Tiers.Should().Be(1);
@ -176,8 +175,7 @@ namespace NzbDrone.Core.Test.IndexerTests.NewznabTests
[Test]
public void should_not_use_aggregrated_id_search_if_no_ids_are_known()
{
_capabilities.SupportedMovieSearchParameters = new[] { "q", "imdbid" };
_capabilities.SupportsAggregateIdSearch = true; // Turns true if indexer supplies supportedParams.
_capabilities.MovieSearchParams = new List<MovieSearchParam> { MovieSearchParam.Q, MovieSearchParam.ImdbId };
_movieSearchCriteria.ImdbId = null;
@ -191,8 +189,7 @@ namespace NzbDrone.Core.Test.IndexerTests.NewznabTests
[Test]
public void should_fallback_to_q()
{
_capabilities.SupportedMovieSearchParameters = new[] { "q", "tmdbid", "imdbid" };
_capabilities.SupportsAggregateIdSearch = true;
_capabilities.MovieSearchParams = new List<MovieSearchParam> { MovieSearchParam.Q, MovieSearchParam.ImdbId, MovieSearchParam.TmdbId };
var results = Subject.GetSearchRequests(_movieSearchCriteria);
results.Tiers.Should().Be(2);

@ -1,4 +1,4 @@
using FluentAssertions;
using FluentAssertions;
using NUnit.Framework;
using NzbDrone.Core.Indexers.Newznab;
using NzbDrone.Core.Test.Framework;

@ -1,4 +1,4 @@
using System;
using System;
using System.Linq;
using FluentAssertions;
using Moq;
@ -15,7 +15,7 @@ namespace NzbDrone.Core.Test.IndexerTests.TorznabTests
[TestFixture]
public class TorznabFixture : CoreTest<Torznab>
{
private NewznabCapabilities _caps;
private IndexerCapabilities _caps;
[SetUp]
public void Setup()
@ -30,7 +30,7 @@ namespace NzbDrone.Core.Test.IndexerTests.TorznabTests
}
};
_caps = new NewznabCapabilities();
_caps = new IndexerCapabilities();
Mocker.GetMock<INewznabCapabilitiesProvider>()
.Setup(v => v.GetCapabilities(It.IsAny<NewznabSettings>()))
.Returns(_caps);
@ -129,8 +129,8 @@ namespace NzbDrone.Core.Test.IndexerTests.TorznabTests
[Test]
public void should_use_pagesize_reported_by_caps()
{
_caps.MaxPageSize = 30;
_caps.DefaultPageSize = 25;
_caps.LimitsMax = 30;
_caps.LimitsDefault = 25;
Subject.PageSize.Should().Be(25);
}

@ -10,6 +10,7 @@
<PackageReference Include="Dapper" Version="2.0.30" />
<PackageReference Include="NBuilder" Version="6.1.0" />
<PackageReference Include="System.Data.SQLite.Core.Servarr" Version="1.0.113.0-0" />
<PackageReference Include="YamlDotNet" Version="8.1.2" />
</ItemGroup>
<ItemGroup Condition="'$(TargetFramework)' == 'netcoreapp3.1'">
<PackageReference Include="coverlet.collector" Version="1.2.1" PrivateAssets="all" />

@ -1,4 +1,4 @@
using FizzWare.NBuilder;
using FizzWare.NBuilder;
using FluentAssertions;
using NUnit.Framework;
using NzbDrone.Core.Indexers;

@ -7,7 +7,6 @@ using NzbDrone.Core.Authentication;
using NzbDrone.Core.Configuration;
using NzbDrone.Core.CustomFilters;
using NzbDrone.Core.Datastore.Converters;
using NzbDrone.Core.History;
using NzbDrone.Core.Indexers;
using NzbDrone.Core.Instrumentation;
using NzbDrone.Core.Jobs;
@ -82,7 +81,6 @@ namespace NzbDrone.Core.Datastore
SqlMapper.AddTypeHandler(new DapperLanguageIntConverter());
SqlMapper.AddTypeHandler(new EmbeddedDocumentConverter<List<Language>>(new LanguageIntConverter()));
SqlMapper.AddTypeHandler(new EmbeddedDocumentConverter<List<string>>());
SqlMapper.AddTypeHandler(new EmbeddedDocumentConverter<ParsedMovieInfo>(new LanguageIntConverter()));
SqlMapper.AddTypeHandler(new EmbeddedDocumentConverter<ReleaseInfo>());
SqlMapper.AddTypeHandler(new EmbeddedDocumentConverter<HashSet<int>>());
SqlMapper.AddTypeHandler(new OsPathConverter());

@ -13,11 +13,11 @@ namespace NzbDrone.Core.IndexerSearch.Definitions
private static readonly Regex BeginningThe = new Regex(@"^the\s", RegexOptions.IgnoreCase | RegexOptions.Compiled);
public List<string> SceneTitles { get; set; }
public virtual bool MonitoredEpisodesOnly { get; set; }
public virtual bool UserInvokedSearch { get; set; }
public virtual bool InteractiveSearch { get; set; }
public string ImdbId { get; set; }
public int TmdbId { get; set; }
public List<int> IndexerIds { get; set; }
public List<string> QueryTitles => SceneTitles.Select(GetQueryTitle).ToList();

@ -1,11 +0,0 @@
using NzbDrone.Core.Messaging.Commands;
namespace NzbDrone.Core.IndexerSearch
{
public class MoviesSearchCommand : Command
{
public string SearchTerm { get; set; }
public override bool SendUpdatesToClient => true;
}
}

@ -1,26 +0,0 @@
using NLog;
using NzbDrone.Common.Instrumentation.Extensions;
using NzbDrone.Core.Messaging.Commands;
namespace NzbDrone.Core.IndexerSearch
{
public class MovieSearchService : IExecute<MoviesSearchCommand>
{
private readonly ISearchForNzb _nzbSearchService;
private readonly Logger _logger;
public MovieSearchService(ISearchForNzb nzbSearchService,
Logger logger)
{
_nzbSearchService = nzbSearchService;
_logger = logger;
}
public void Execute(MoviesSearchCommand message)
{
var decisions = _nzbSearchService.MovieSearch(message.SearchTerm, false, false);
_logger.ProgressInfo("Movie search completed. {0} reports downloaded.", decisions.Count);
}
}
}

@ -0,0 +1,28 @@
namespace NzbDrone.Core.IndexerSearch
{
public class NewznabRequest
{
public int id { get; set; }
public string t { get; set; }
public string q { get; set; }
public string cat { get; set; }
public string imdbid { get; set; }
public string tmdbid { get; set; }
public string extended { get; set; }
public string limit { get; set; }
public string offset { get; set; }
public string rid { get; set; }
public string tvdbid { get; set; }
public string season { get; set; }
public string ep { get; set; }
public string album { get; set; }
public string artist { get; set; }
public string label { get; set; }
public string track { get; set; }
public string year { get; set; }
public string genre { get; set; }
public string author { get; set; }
public string title { get; set; }
public string configured { get; set; }
}
}

@ -0,0 +1,91 @@
using System;
using System.Collections.Generic;
using System.Globalization;
using System.Linq;
using System.Text.RegularExpressions;
using System.Threading;
using System.Xml.Linq;
using NzbDrone.Core.Parser.Model;
namespace NzbDrone.Core.IndexerSearch
{
public class NewznabResults
{
private static readonly XNamespace _AtomNs = "http://www.w3.org/2005/Atom";
private static readonly XNamespace _TorznabNs = "http://torznab.com/schemas/2015/feed";
// filters control characters but allows only properly-formed surrogate sequences
// https://stackoverflow.com/a/961504
private static readonly Regex _InvalidXmlChars = new Regex(
@"(?<![\uD800-\uDBFF])[\uDC00-\uDFFF]|[\uD800-\uDBFF](?![\uDC00-\uDFFF])|[\x00-\x08\x0B\x0C\x0E-\x1F\x7F-\x9F\uFEFF\uFFFE\uFFFF]",
RegexOptions.Compiled);
public List<ReleaseInfo> Releases;
private static string RemoveInvalidXMLChars(string text)
{
if (text == null)
{
return null;
}
return _InvalidXmlChars.Replace(text, "");
}
private static string XmlDateFormat(DateTime dt)
{
Thread.CurrentThread.CurrentCulture = new CultureInfo("en-US");
//Sat, 14 Mar 2015 17:10:42 -0400
return $"{dt:ddd, dd MMM yyyy HH:mm:ss} " + $"{dt:zzz}".Replace(":", "");
}
private static XElement GetTorznabElement(string name, object value)
{
if (value == null)
{
return null;
}
return new XElement(_TorznabNs + "attr", new XAttribute("name", name), new XAttribute("value", value));
}
public string ToXml()
{
// IMPORTANT: We can't use Uri.ToString(), because it generates URLs without URL encode (links with unicode
// characters are broken). We must use Uri.AbsoluteUri instead that handles encoding correctly
var xdoc = new XDocument(
new XDeclaration("1.0", "UTF-8", null),
new XElement("rss",
new XAttribute("version", "1.0"),
new XAttribute(XNamespace.Xmlns + "atom", _AtomNs.NamespaceName),
new XAttribute(XNamespace.Xmlns + "torznab", _TorznabNs.NamespaceName),
new XElement("channel",
new XElement(_AtomNs + "link",
new XAttribute("rel", "self"),
new XAttribute("type", "application/rss+xml")),
new XElement("title", "Prowlarr"),
from r in Releases
let t = (r as TorrentInfo) ?? new TorrentInfo()
select new XElement("item",
new XElement("title", RemoveInvalidXMLChars(r.Title)),
new XElement("guid", r.Guid), // GUID and (Link or Magnet) are mandatory
new XElement("prowlarrindexer", new XAttribute("id", r.IndexerId), r.Indexer),
r.PublishDate == DateTime.MinValue ? new XElement("pubDate", XmlDateFormat(DateTime.Now)) : new XElement("pubDate", XmlDateFormat(r.PublishDate)),
new XElement("size", r.Size),
new XElement(
"enclosure",
new XAttribute("length", r.Size),
new XAttribute("type", "application/x-bittorrent")),
GetTorznabElement("rageid", r.TvRageId),
GetTorznabElement("thetvdb", r.TvdbId),
GetTorznabElement("imdb", r.ImdbId.ToString("D7")),
GetTorznabElement("tmdb", r.TmdbId),
GetTorznabElement("seeders", t.Seeders),
GetTorznabElement("peers", t.Peers),
GetTorznabElement("infohash", RemoveInvalidXMLChars(r.Guid))))));
return xdoc.Declaration + Environment.NewLine + xdoc;
}
}
}

@ -1,5 +1,6 @@
using System;
using System.Collections.Generic;
using System.Linq;
using System.Threading.Tasks;
using NLog;
using NzbDrone.Common.Extensions;
@ -13,7 +14,8 @@ namespace NzbDrone.Core.IndexerSearch
{
public interface ISearchForNzb
{
List<ReleaseInfo> MovieSearch(string movieId, bool userInvokedSearch, bool interactiveSearch);
List<ReleaseInfo> Search(string query, List<int> indexerIds, bool userInvokedSearch, bool interactiveSearch);
NewznabResults Search(NewznabRequest request, List<int> indexerIds, bool userInvokedSearch, bool interactiveSearch);
}
public class NzbSearchService : ISearchForNzb
@ -28,14 +30,21 @@ namespace NzbDrone.Core.IndexerSearch
_logger = logger;
}
public List<ReleaseInfo> MovieSearch(string movie, bool userInvokedSearch, bool interactiveSearch)
public List<ReleaseInfo> Search(string query, List<int> indexerIds, bool userInvokedSearch, bool interactiveSearch)
{
var searchSpec = Get<MovieSearchCriteria>(movie, userInvokedSearch, interactiveSearch);
var searchSpec = Get<MovieSearchCriteria>(query, indexerIds, userInvokedSearch, interactiveSearch);
return Dispatch(indexer => indexer.Fetch(searchSpec), searchSpec);
}
private TSpec Get<TSpec>(string movie, bool userInvokedSearch, bool interactiveSearch)
public NewznabResults Search(NewznabRequest request, List<int> indexerIds, bool userInvokedSearch, bool interactiveSearch)
{
var searchSpec = Get<MovieSearchCriteria>(request.q, indexerIds, userInvokedSearch, interactiveSearch);
return new NewznabResults { Releases = Dispatch(indexer => indexer.Fetch(searchSpec), searchSpec) };
}
private TSpec Get<TSpec>(string query, List<int> indexerIds, bool userInvokedSearch, bool interactiveSearch)
where TSpec : SearchCriteriaBase, new()
{
var spec = new TSpec()
@ -44,7 +53,8 @@ namespace NzbDrone.Core.IndexerSearch
InteractiveSearch = interactiveSearch
};
spec.SceneTitles = new List<string> { movie };
spec.SceneTitles = new List<string> { query };
spec.IndexerIds = indexerIds;
return spec;
}
@ -55,6 +65,11 @@ namespace NzbDrone.Core.IndexerSearch
_indexerFactory.InteractiveSearchEnabled() :
_indexerFactory.AutomaticSearchEnabled();
if (criteriaBase.IndexerIds != null && criteriaBase.IndexerIds.Count > 0)
{
indexers = indexers.Where(i => criteriaBase.IndexerIds.Contains(i.Definition.Id)).ToList();
}
var reports = new List<ReleaseInfo>();
_logger.ProgressInfo("Searching {0} indexers for {1}", indexers.Count, criteriaBase.QueryTitles.Join(", "));

@ -20,7 +20,7 @@ namespace NzbDrone.Core.Indexers.Newznab
public override DownloadProtocol Protocol => DownloadProtocol.Usenet;
public override IndexerPrivacy Privacy => IndexerPrivacy.Private;
public override int PageSize => _capabilitiesProvider.GetCapabilities(Settings).DefaultPageSize;
public override int PageSize => _capabilitiesProvider.GetCapabilities(Settings).LimitsDefault.Value;
public override IIndexerRequestGenerator GetRequestGenerator()
{
@ -36,6 +36,14 @@ namespace NzbDrone.Core.Indexers.Newznab
return new NewznabRssParser(Settings);
}
public override IndexerCapabilities GetCapabilities()
{
// TODO: This uses indexer capabilities when called so we don't have to keep up with all of them
// however, this is not pulled on a all pull from UI, doing so will kill the UI load if an indexer is down
// should we just purge and manage
return _capabilitiesProvider.GetCapabilities(Settings);
}
public override IEnumerable<ProviderDefinition> DefaultDefinitions
{
get
@ -75,7 +83,8 @@ namespace NzbDrone.Core.Indexers.Newznab
Protocol = DownloadProtocol.Usenet,
Privacy = IndexerPrivacy.Private,
SupportsRss = SupportsRss,
SupportsSearch = SupportsSearch
SupportsSearch = SupportsSearch,
Capabilities = Capabilities
};
}
@ -107,15 +116,15 @@ namespace NzbDrone.Core.Indexers.Newznab
failures.AddIfNotNull(TestCapabilities());
}
protected static List<int> CategoryIds(List<NewznabCategory> categories)
protected static List<int> CategoryIds(List<IndexerCategory> categories)
{
var l = categories.Select(c => c.Id).ToList();
foreach (var category in categories)
{
if (category.Subcategories != null)
if (category.SubCategories != null)
{
l.AddRange(CategoryIds(category.Subcategories));
l.AddRange(CategoryIds(category.SubCategories));
}
}
@ -139,14 +148,14 @@ namespace NzbDrone.Core.Indexers.Newznab
}
}
if (capabilities.SupportedSearchParameters != null && capabilities.SupportedSearchParameters.Contains("q"))
if (capabilities.SearchParams != null && capabilities.SearchParams.Contains(SearchParam.Q))
{
return null;
}
if (capabilities.SupportedMovieSearchParameters != null &&
new[] { "q", "imdbid" }.Any(v => capabilities.SupportedMovieSearchParameters.Contains(v)) &&
new[] { "imdbtitle", "imdbyear" }.All(v => capabilities.SupportedMovieSearchParameters.Contains(v)))
if (capabilities.MovieSearchParams != null &&
new[] { MovieSearchParam.Q, MovieSearchParam.ImdbId }.Any(v => capabilities.MovieSearchParams.Contains(v)) &&
new[] { MovieSearchParam.ImdbTitle, MovieSearchParam.ImdbYear }.All(v => capabilities.MovieSearchParams.Contains(v)))
{
return null;
}

@ -1,33 +0,0 @@
using System.Collections.Generic;
namespace NzbDrone.Core.Indexers.Newznab
{
public class NewznabCapabilities
{
public int DefaultPageSize { get; set; }
public int MaxPageSize { get; set; }
public string[] SupportedSearchParameters { get; set; }
public string[] SupportedMovieSearchParameters { get; set; }
public bool SupportsAggregateIdSearch { get; set; }
public List<NewznabCategory> Categories { get; set; }
public NewznabCapabilities()
{
DefaultPageSize = 100;
MaxPageSize = 100;
SupportedSearchParameters = new[] { "q" };
SupportedMovieSearchParameters = new[] { "q", "imdbid", "imdbtitle", "imdbyear" };
SupportsAggregateIdSearch = false;
Categories = new List<NewznabCategory>();
}
}
public class NewznabCategory
{
public int Id { get; set; }
public string Name { get; set; }
public string Description { get; set; }
public List<NewznabCategory> Subcategories { get; set; }
}
}

@ -12,23 +12,23 @@ namespace NzbDrone.Core.Indexers.Newznab
{
public interface INewznabCapabilitiesProvider
{
NewznabCapabilities GetCapabilities(NewznabSettings settings);
IndexerCapabilities GetCapabilities(NewznabSettings settings);
}
public class NewznabCapabilitiesProvider : INewznabCapabilitiesProvider
{
private readonly ICached<NewznabCapabilities> _capabilitiesCache;
private readonly ICached<IndexerCapabilities> _capabilitiesCache;
private readonly IHttpClient _httpClient;
private readonly Logger _logger;
public NewznabCapabilitiesProvider(ICacheManager cacheManager, IHttpClient httpClient, Logger logger)
{
_capabilitiesCache = cacheManager.GetCache<NewznabCapabilities>(GetType());
_capabilitiesCache = cacheManager.GetCache<IndexerCapabilities>(GetType());
_httpClient = httpClient;
_logger = logger;
}
public NewznabCapabilities GetCapabilities(NewznabSettings indexerSettings)
public IndexerCapabilities GetCapabilities(NewznabSettings indexerSettings)
{
var key = indexerSettings.ToJson();
var capabilities = _capabilitiesCache.Get(key, () => FetchCapabilities(indexerSettings), TimeSpan.FromDays(7));
@ -36,9 +36,9 @@ namespace NzbDrone.Core.Indexers.Newznab
return capabilities;
}
private NewznabCapabilities FetchCapabilities(NewznabSettings indexerSettings)
private IndexerCapabilities FetchCapabilities(NewznabSettings indexerSettings)
{
var capabilities = new NewznabCapabilities();
var capabilities = new IndexerCapabilities();
var url = string.Format("{0}{1}?t=caps", indexerSettings.BaseUrl.TrimEnd('/'), indexerSettings.ApiPath.TrimEnd('/'));
@ -81,9 +81,9 @@ namespace NzbDrone.Core.Indexers.Newznab
return capabilities;
}
private NewznabCapabilities ParseCapabilities(HttpResponse response)
private IndexerCapabilities ParseCapabilities(HttpResponse response)
{
var capabilities = new NewznabCapabilities();
var capabilities = new IndexerCapabilities();
var xDoc = XDocument.Parse(response.Content);
@ -104,8 +104,8 @@ namespace NzbDrone.Core.Indexers.Newznab
var xmlLimits = xmlRoot.Element("limits");
if (xmlLimits != null)
{
capabilities.DefaultPageSize = int.Parse(xmlLimits.Attribute("default").Value);
capabilities.MaxPageSize = int.Parse(xmlLimits.Attribute("max").Value);
capabilities.LimitsDefault = int.Parse(xmlLimits.Attribute("default").Value);
capabilities.LimitsMax = int.Parse(xmlLimits.Attribute("max").Value);
}
var xmlSearching = xmlRoot.Element("searching");
@ -114,22 +114,81 @@ namespace NzbDrone.Core.Indexers.Newznab
var xmlBasicSearch = xmlSearching.Element("search");
if (xmlBasicSearch == null || xmlBasicSearch.Attribute("available").Value != "yes")
{
capabilities.SupportedSearchParameters = null;
capabilities.SearchParams = new List<SearchParam>();
}
else if (xmlBasicSearch.Attribute("supportedParams") != null)
{
capabilities.SupportedSearchParameters = xmlBasicSearch.Attribute("supportedParams").Value.Split(',');
foreach (var param in xmlBasicSearch.Attribute("supportedParams").Value.Split(','))
{
if (Enum.TryParse(param, true, out SearchParam searchParam))
{
capabilities.SearchParams.AddIfNotNull(searchParam);
}
}
}
var xmlMovieSearch = xmlSearching.Element("movie-search");
if (xmlMovieSearch == null || xmlMovieSearch.Attribute("available").Value != "yes")
{
capabilities.SupportedMovieSearchParameters = null;
capabilities.MovieSearchParams = new List<MovieSearchParam>();
}
else if (xmlMovieSearch.Attribute("supportedParams") != null)
{
capabilities.SupportedMovieSearchParameters = xmlMovieSearch.Attribute("supportedParams").Value.Split(',');
capabilities.SupportsAggregateIdSearch = true;
foreach (var param in xmlMovieSearch.Attribute("supportedParams").Value.Split(','))
{
if (Enum.TryParse(param, true, out MovieSearchParam searchParam))
{
capabilities.MovieSearchParams.AddIfNotNull(searchParam);
}
}
}
var xmlTvSearch = xmlSearching.Element("tv-search");
if (xmlTvSearch == null || xmlTvSearch.Attribute("available").Value != "yes")
{
capabilities.TvSearchParams = new List<TvSearchParam>();
}
else if (xmlTvSearch.Attribute("supportedParams") != null)
{
foreach (var param in xmlTvSearch.Attribute("supportedParams").Value.Split(','))
{
if (Enum.TryParse(param, true, out TvSearchParam searchParam))
{
capabilities.TvSearchParams.AddIfNotNull(searchParam);
}
}
}
var xmlAudioSearch = xmlSearching.Element("audio-search");
if (xmlAudioSearch == null || xmlAudioSearch.Attribute("available").Value != "yes")
{
capabilities.MusicSearchParams = new List<MusicSearchParam>();
}
else if (xmlAudioSearch.Attribute("supportedParams") != null)
{
foreach (var param in xmlAudioSearch.Attribute("supportedParams").Value.Split(','))
{
if (Enum.TryParse(param, true, out MusicSearchParam searchParam))
{
capabilities.MusicSearchParams.AddIfNotNull(searchParam);
}
}
}
var xmlBookSearch = xmlSearching.Element("book-search");
if (xmlBookSearch == null || xmlBookSearch.Attribute("available").Value != "yes")
{
capabilities.BookSearchParams = new List<BookSearchParam>();
}
else if (xmlBookSearch.Attribute("supportedParams") != null)
{
foreach (var param in xmlBookSearch.Attribute("supportedParams").Value.Split(','))
{
if (Enum.TryParse(param, true, out BookSearchParam searchParam))
{
capabilities.BookSearchParams.AddIfNotNull(searchParam);
}
}
}
}
@ -138,17 +197,16 @@ namespace NzbDrone.Core.Indexers.Newznab
{
foreach (var xmlCategory in xmlCategories.Elements("category"))
{
var cat = new NewznabCategory
var cat = new IndexerCategory
{
Id = int.Parse(xmlCategory.Attribute("id").Value),
Name = xmlCategory.Attribute("name").Value,
Description = xmlCategory.Attribute("description") != null ? xmlCategory.Attribute("description").Value : string.Empty,
Subcategories = new List<NewznabCategory>()
Description = xmlCategory.Attribute("description") != null ? xmlCategory.Attribute("description").Value : string.Empty
};
foreach (var xmlSubcat in xmlCategory.Elements("subcat"))
{
cat.Subcategories.Add(new NewznabCategory
cat.SubCategories.Add(new IndexerCategory
{
Id = int.Parse(xmlSubcat.Attribute("id").Value),
Name = xmlSubcat.Attribute("name").Value,

@ -28,8 +28,8 @@ namespace NzbDrone.Core.Indexers.Newznab
{
var capabilities = _capabilitiesProvider.GetCapabilities(Settings);
return capabilities.SupportedSearchParameters != null &&
capabilities.SupportedSearchParameters.Contains("q");
return capabilities.SearchParams != null &&
capabilities.SearchParams.Contains(SearchParam.Q);
}
}
@ -39,8 +39,8 @@ namespace NzbDrone.Core.Indexers.Newznab
{
var capabilities = _capabilitiesProvider.GetCapabilities(Settings);
return capabilities.SupportedMovieSearchParameters != null &&
capabilities.SupportedMovieSearchParameters.Contains("imdbid");
return capabilities.MovieSearchParams != null &&
capabilities.MovieSearchParams.Contains(MovieSearchParam.ImdbId);
}
}
@ -50,8 +50,8 @@ namespace NzbDrone.Core.Indexers.Newznab
{
var capabilities = _capabilitiesProvider.GetCapabilities(Settings);
return capabilities.SupportedMovieSearchParameters != null &&
capabilities.SupportedMovieSearchParameters.Contains("tmdbid");
return capabilities.MovieSearchParams != null &&
capabilities.MovieSearchParams.Contains(MovieSearchParam.TmdbId);
}
}
@ -61,7 +61,8 @@ namespace NzbDrone.Core.Indexers.Newznab
{
var capabilities = _capabilitiesProvider.GetCapabilities(Settings);
return capabilities.SupportsAggregateIdSearch;
// TODO: Fix this, return capabilities.SupportsAggregateIdSearch;
return true;
}
}
@ -72,11 +73,11 @@ namespace NzbDrone.Core.Indexers.Newznab
var capabilities = _capabilitiesProvider.GetCapabilities(Settings);
// Some indexers might forget to enable movie search, but normal search still works fine. Thus we force a normal search.
if (capabilities.SupportedMovieSearchParameters != null)
if (capabilities.MovieSearchParams != null)
{
pageableRequests.Add(GetPagedRequests(MaxPages, Settings.Categories, "movie", ""));
}
else if (capabilities.SupportedSearchParameters != null)
else if (capabilities.SearchParams != null)
{
pageableRequests.Add(GetPagedRequests(MaxPages, Settings.Categories, "search", ""));
}

@ -1,6 +1,5 @@
using System;
using System.Collections.Generic;
using System.Globalization;
using System.Linq;
using System.Xml.Linq;
using NzbDrone.Common.Extensions;
@ -148,19 +147,6 @@ namespace NzbDrone.Core.Indexers.Newznab
return 0;
}
protected virtual string GetImdbTitle(XElement item)
{
var imdbTitle = TryGetNewznabAttribute(item, "imdbtitle");
if (!imdbTitle.IsNullOrWhiteSpace())
{
return CultureInfo.CurrentCulture.TextInfo.ToTitleCase(
Parser.Parser.ReplaceGermanUmlauts(
Parser.Parser.NormalizeTitle(imdbTitle).Replace(" ", ".")));
}
return string.Empty;
}
protected virtual int GetImdbYear(XElement item)
{
var imdbYearString = TryGetNewznabAttribute(item, "imdbyear");

@ -20,7 +20,8 @@ namespace NzbDrone.Core.Indexers.Torznab
public override DownloadProtocol Protocol => DownloadProtocol.Torrent;
public override IndexerPrivacy Privacy => IndexerPrivacy.Private;
public override int PageSize => _capabilitiesProvider.GetCapabilities(Settings).DefaultPageSize;
public override int PageSize => _capabilitiesProvider.GetCapabilities(Settings).LimitsDefault.Value;
public override IIndexerRequestGenerator GetRequestGenerator()
{
@ -63,7 +64,8 @@ namespace NzbDrone.Core.Indexers.Torznab
Settings = settings,
Protocol = DownloadProtocol.Usenet,
SupportsRss = SupportsRss,
SupportsSearch = SupportsSearch
SupportsSearch = SupportsSearch,
Capabilities = new IndexerCapabilities()
};
}
@ -95,15 +97,15 @@ namespace NzbDrone.Core.Indexers.Torznab
failures.AddIfNotNull(TestCapabilities());
}
protected static List<int> CategoryIds(List<NewznabCategory> categories)
protected static List<int> CategoryIds(List<IndexerCategory> categories)
{
var l = categories.Select(c => c.Id).ToList();
foreach (var category in categories)
{
if (category.Subcategories != null)
if (category.SubCategories != null)
{
l.AddRange(CategoryIds(category.Subcategories));
l.AddRange(CategoryIds(category.SubCategories));
}
}
@ -127,14 +129,14 @@ namespace NzbDrone.Core.Indexers.Torznab
}
}
if (capabilities.SupportedSearchParameters != null && capabilities.SupportedSearchParameters.Contains("q"))
if (capabilities.SearchParams != null && capabilities.SearchParams.Contains(SearchParam.Q))
{
return null;
}
if (capabilities.SupportedMovieSearchParameters != null &&
new[] { "q", "imdbid" }.Any(v => capabilities.SupportedMovieSearchParameters.Contains(v)) &&
new[] { "imdbtitle", "imdbyear" }.All(v => capabilities.SupportedMovieSearchParameters.Contains(v)))
if (capabilities.MovieSearchParams != null &&
new[] { MovieSearchParam.Q, MovieSearchParam.ImdbId }.Any(v => capabilities.MovieSearchParams.Contains(v)) &&
new[] { MovieSearchParam.ImdbTitle, MovieSearchParam.ImdbYear }.All(v => capabilities.MovieSearchParams.Contains(v)))
{
return null;
}

@ -1,11 +1,8 @@
using System.Collections.Generic;
using System.Linq;
using System.Text.RegularExpressions;
using FluentValidation;
using NzbDrone.Common.Extensions;
using NzbDrone.Core.Annotations;
using NzbDrone.Core.Indexers.Newznab;
using NzbDrone.Core.Parser.Model;
using NzbDrone.Core.Validation;
namespace NzbDrone.Core.Indexers.Torznab
@ -47,22 +44,14 @@ namespace NzbDrone.Core.Indexers.Torznab
}
}
public class TorznabSettings : NewznabSettings, ITorrentIndexerSettings
public class TorznabSettings : NewznabSettings
{
private static readonly TorznabSettingsValidator Validator = new TorznabSettingsValidator();
public TorznabSettings()
{
MinimumSeeders = IndexerDefaults.MINIMUM_SEEDERS;
RequiredFlags = new List<int>();
}
[FieldDefinition(8, Type = FieldType.Number, Label = "Minimum Seeders", HelpText = "Minimum number of seeders required.", Advanced = true)]
public int MinimumSeeders { get; set; }
[FieldDefinition(9, Type = FieldType.TagSelect, SelectOptions = typeof(IndexerFlags), Label = "Required Flags", HelpText = "What indexer flags are required?", HelpLink = "https://github.com/Prowlarr/Prowlarr/wiki/Indexer-Flags#1-required-flags", Advanced = true)]
public IEnumerable<int> RequiredFlags { get; set; }
public override NzbDroneValidationResult Validate()
{
return new NzbDroneValidationResult(Validator.Validate(this));

@ -86,6 +86,11 @@ namespace NzbDrone.Core.Indexers
return requests;
}
public override IndexerCapabilities GetCapabilities()
{
return Capabilities;
}
protected virtual IList<ReleaseInfo> FetchReleases(Func<IIndexerRequestGenerator, IndexerPageableRequestChain> pageableRequestChainSelector, bool isRecent = false)
{
var releases = new List<ReleaseInfo>();

@ -16,5 +16,7 @@ namespace NzbDrone.Core.Indexers
IList<ReleaseInfo> FetchRecent();
IList<ReleaseInfo> Fetch(MovieSearchCriteria searchCriteria);
IndexerCapabilities GetCapabilities();
}
}

@ -68,6 +68,8 @@ namespace NzbDrone.Core.Indexers
public abstract IList<ReleaseInfo> FetchRecent();
public abstract IList<ReleaseInfo> Fetch(MovieSearchCriteria searchCriteria);
public abstract IndexerCapabilities GetCapabilities();
protected virtual IList<ReleaseInfo> CleanupReleases(IEnumerable<ReleaseInfo> releases)
{
var result = releases.DistinctBy(v => v.Guid).ToList();

@ -13,13 +13,16 @@ namespace NzbDrone.Core.Indexers
ImdbId,
TvdbId,
RId,
TvMazeId
}
public enum MovieSearchParam
{
Q,
ImdbId,
TmdbId
TmdbId,
ImdbTitle,
ImdbYear
}
public enum MusicSearchParam
@ -31,12 +34,23 @@ namespace NzbDrone.Core.Indexers
Year
}
public enum SearchParam
{
Q
}
public enum BookSearchParam
{
Q
}
public class IndexerCapabilities
{
public int? LimitsMax { get; set; }
public int? LimitsDefault { get; set; }
public bool SearchAvailable { get; set; }
public List<SearchParam> SearchParams;
public bool SearchAvailable => SearchParams.Count > 0;
public List<TvSearchParam> TvSearchParams;
public bool TvSearchAvailable => TvSearchParams.Count > 0;
@ -45,6 +59,7 @@ namespace NzbDrone.Core.Indexers
public bool TvSearchImdbAvailable => TvSearchParams.Contains(TvSearchParam.ImdbId);
public bool TvSearchTvdbAvailable => TvSearchParams.Contains(TvSearchParam.TvdbId);
public bool TvSearchTvRageAvailable => TvSearchParams.Contains(TvSearchParam.RId);
public bool TvSearchTvMazeAvailable => TvSearchParams.Contains(TvSearchParam.TvMazeId);
public List<MovieSearchParam> MovieSearchParams;
public bool MovieSearchAvailable => MovieSearchParams.Count > 0;
@ -58,17 +73,18 @@ namespace NzbDrone.Core.Indexers
public bool MusicSearchLabelAvailable => MusicSearchParams.Contains(MusicSearchParam.Label);
public bool MusicSearchYearAvailable => MusicSearchParams.Contains(MusicSearchParam.Year);
public bool BookSearchAvailable { get; set; }
public List<BookSearchParam> BookSearchParams;
public bool BookSearchAvailable => BookSearchParams.Count > 0;
public List<IndexerCategory> Categories { get; private set; }
public IndexerCapabilities()
{
SearchAvailable = true;
SearchParams = new List<SearchParam>();
TvSearchParams = new List<TvSearchParam>();
MovieSearchParams = new List<MovieSearchParam>();
MusicSearchParams = new List<MusicSearchParam>();
BookSearchAvailable = false;
BookSearchParams = new List<BookSearchParam>();
Categories = new List<IndexerCategory>();
}
@ -181,6 +197,11 @@ namespace NzbDrone.Core.Indexers
parameters.Add("rid");
}
if (TvSearchTvMazeAvailable)
{
parameters.Add("tvmazeid");
}
return string.Join(",", parameters);
}
@ -244,7 +265,7 @@ namespace NzbDrone.Core.Indexers
{
var subCategories = Categories.SelectMany(c => c.SubCategories);
var allCategories = Categories.Concat(subCategories);
var supportsCategory = allCategories.Any(i => categories.Any(c => c == i.ID));
var supportsCategory = allCategories.Any(i => categories.Any(c => c == i.Id));
return supportsCategory;
}
@ -280,13 +301,13 @@ namespace NzbDrone.Core.Indexers
new XAttribute("available", BookSearchAvailable ? "yes" : "no"),
new XAttribute("supportedParams", SupportedBookSearchParams))),
new XElement("categories",
from c in Categories.OrderBy(x => x.ID < 100000 ? "z" + x.ID.ToString() : x.Name)
from c in Categories.OrderBy(x => x.Id < 100000 ? "z" + x.Id.ToString() : x.Name)
select new XElement("category",
new XAttribute("id", c.ID),
new XAttribute("id", c.Id),
new XAttribute("name", c.Name),
from sc in c.SubCategories
select new XElement("subcat",
new XAttribute("id", sc.ID),
new XAttribute("id", sc.Id),
new XAttribute("name", sc.Name))))));
return xdoc;
}
@ -296,12 +317,12 @@ namespace NzbDrone.Core.Indexers
public static IndexerCapabilities Concat(IndexerCapabilities left, IndexerCapabilities right)
{
left.SearchAvailable = left.SearchAvailable || right.SearchAvailable;
left.SearchParams = left.SearchParams.Union(right.SearchParams).ToList();
left.TvSearchParams = left.TvSearchParams.Union(right.TvSearchParams).ToList();
left.MovieSearchParams = left.MovieSearchParams.Union(right.MovieSearchParams).ToList();
left.MusicSearchParams = left.MusicSearchParams.Union(right.MusicSearchParams).ToList();
left.BookSearchAvailable = left.BookSearchAvailable || right.BookSearchAvailable;
left.Categories.AddRange(right.Categories.Where(x => x.ID < 100000).Except(left.Categories)); // exclude indexer specific categories (>= 100000)
left.BookSearchParams = left.BookSearchParams.Union(right.BookSearchParams).ToList();
left.Categories.AddRange(right.Categories.Where(x => x.Id < 100000).Except(left.Categories)); // exclude indexer specific categories (>= 100000)
return left;
}
}

@ -5,8 +5,9 @@ namespace NzbDrone.Core.Indexers
{
public class IndexerCategory
{
public int ID { get; set; }
public int Id { get; set; }
public string Name { get; set; }
public string Description { get; set; }
public List<IndexerCategory> SubCategories { get; private set; }
@ -14,7 +15,7 @@ namespace NzbDrone.Core.Indexers
public IndexerCategory(int id, string name)
{
ID = id;
Id = id;
Name = name;
SubCategories = new List<IndexerCategory>();
}
@ -25,14 +26,14 @@ namespace NzbDrone.Core.Indexers
public JToken ToJson() =>
new JObject
{
["ID"] = ID,
["ID"] = Id,
["Name"] = Name
};
public override bool Equals(object obj) => (obj as IndexerCategory)?.ID == ID;
public override bool Equals(object obj) => (obj as IndexerCategory)?.Id == Id;
// Get Hash code should be calculated off read only properties.
// ID is not readonly
public override int GetHashCode() => ID;
public override int GetHashCode() => Id;
}
}

@ -1,27 +0,0 @@
using System.Collections.Generic;
using Newtonsoft.Json;
using NzbDrone.Core.Languages;
namespace NzbDrone.Core.Parser.Model
{
public class ParsedMovieInfo
{
public string MovieTitle { get; set; }
public string OriginalTitle { get; set; }
public string ReleaseTitle { get; set; }
public string SimpleReleaseTitle { get; set; }
public List<Language> Languages { get; set; } = new List<Language>();
public string ReleaseGroup { get; set; }
public string ReleaseHash { get; set; }
public string Edition { get; set; }
public int Year { get; set; }
public string ImdbId { get; set; }
[JsonIgnore]
public Dictionary<string, object> ExtraInfo { get; set; } = new Dictionary<string, object>();
public override string ToString()
{
return string.Format("{0} - {1}", MovieTitle, Year);
}
}
}

@ -1,4 +1,4 @@
using System;
using System;
using System.Text;
using NzbDrone.Core.Indexers;
@ -19,6 +19,7 @@ namespace NzbDrone.Core.Parser.Model
public int TvdbId { get; set; }
public int TvRageId { get; set; }
public int ImdbId { get; set; }
public int TmdbId { get; set; }
public DateTime PublishDate { get; set; }
public string Origin { get; set; }

@ -1,9 +0,0 @@
namespace NzbDrone.Core.Parser.Model
{
public class SeriesTitleInfo
{
public string Title { get; set; }
public string TitleWithoutYear { get; set; }
public int Year { get; set; }
}
}

@ -1,584 +0,0 @@
using System;
using System.Collections.Generic;
using System.IO;
using System.Linq;
using System.Text.RegularExpressions;
using NLog;
using NzbDrone.Common.Extensions;
using NzbDrone.Common.Instrumentation;
using NzbDrone.Core.Parser.Model;
#if !LIBRARY
#endif
namespace NzbDrone.Core.Parser
{
public static class Parser
{
private static readonly Logger Logger = NzbDroneLogger.GetLogger(typeof(Parser));
private static readonly Regex ReportYearRegex = new Regex(@"^.*(?<year>(19|20)\d{2}).*$", RegexOptions.Compiled);
private static readonly Regex EditionRegex = new Regex(@"\(?\b(?<edition>(((Recut.|Extended.|Ultimate.)?(Director.?s|Collector.?s|Theatrical|Ultimate|Final(?=(.(Cut|Edition|Version)))|Extended|Rogue|Special|Despecialized|\d{2,3}(th)?.Anniversary)(.(Cut|Edition|Version))?(.(Extended|Uncensored|Remastered|Unrated|Uncut|IMAX|Fan.?Edit))?|((Uncensored|Remastered|Unrated|Uncut|IMAX|Fan.?Edit|Edition|Restored|((2|3|4)in1))))))\b\)?", RegexOptions.Compiled | RegexOptions.IgnoreCase);
private static readonly Regex ReportEditionRegex = new Regex(@"^.+?" + EditionRegex, RegexOptions.Compiled | RegexOptions.IgnoreCase);
private static readonly Regex[] ReportMovieTitleRegex = new[]
{
//Some german or french tracker formats (missing year, ...) (Only applies to german and French/TrueFrench releases) - see ParserFixture for examples and tests
new Regex(@"^(?<title>(?![(\[]).+?)((\W|_))(" + EditionRegex + @".{1,3})?(?:(?<!(19|20)\d{2}.*?)(German|French|TrueFrench))(.+?)(?=((19|20)\d{2}|$))(?<year>(19|20)\d{2}(?!p|i|\d+|\]|\W\d+))?(\W+|_|$)(?!\\)", RegexOptions.IgnoreCase | RegexOptions.Compiled),
//Special, Despecialized, etc. Edition Movies, e.g: Mission.Impossible.3.Special.Edition.2011
new Regex(@"^(?<title>(?![(\[]).+?)?(?:(?:[-_\W](?<![)\[!]))*" + EditionRegex + @".{1,3}(?<year>(1(8|9)|20)\d{2}(?!p|i|\d+|\]|\W\d+)))+(\W+|_|$)(?!\\)",
RegexOptions.IgnoreCase | RegexOptions.Compiled),
//Special, Despecialized, etc. Edition Movies, e.g: Mission.Impossible.3.2011.Special.Edition //TODO: Seems to slow down parsing heavily!
/*new Regex(@"^(?<title>(?![(\[]).+?)?(?:(?:[-_\W](?<![)\[!]))*(?<year>(19|20)\d{2}(?!p|i|(19|20)\d{2}|\]|\W(19|20)\d{2})))+(\W+|_|$)(?!\\)\(?(?<edition>(((Extended.|Ultimate.)?(Director.?s|Collector.?s|Theatrical|Ultimate|Final(?=(.(Cut|Edition|Version)))|Extended|Rogue|Special|Despecialized|\d{2,3}(th)?.Anniversary)(.(Cut|Edition|Version))?(.(Extended|Uncensored|Remastered|Unrated|Uncut|IMAX|Fan.?Edit))?|((Uncensored|Remastered|Unrated|Uncut|IMAX|Fan.?Edit|Edition|Restored|((2|3|4)in1))))))\)?",
RegexOptions.IgnoreCase | RegexOptions.Compiled),*/
//Normal movie format, e.g: Mission.Impossible.3.2011
new Regex(@"^(?<title>(?![(\[]).+?)?(?:(?:[-_\W](?<![)\[!]))*(?<year>(1(8|9)|20)\d{2}(?!p|i|(1(8|9)|20)\d{2}|\]|\W(1(8|9)|20)\d{2})))+(\W+|_|$)(?!\\)", RegexOptions.IgnoreCase | RegexOptions.Compiled),
//PassThePopcorn Torrent names: Star.Wars[PassThePopcorn]
new Regex(@"^(?<title>.+?)?(?:(?:[-_\W](?<![()\[!]))*(?<year>(\[\w *\])))+(\W+|_|$)(?!\\)", RegexOptions.IgnoreCase | RegexOptions.Compiled),
//That did not work? Maybe some tool uses [] for years. Who would do that?
new Regex(@"^(?<title>(?![(\[]).+?)?(?:(?:[-_\W](?<![)!]))*(?<year>(1(8|9)|20)\d{2}(?!p|i|\d+|\W\d+)))+(\W+|_|$)(?!\\)", RegexOptions.IgnoreCase | RegexOptions.Compiled),
//As a last resort for movies that have ( or [ in their title.
new Regex(@"^(?<title>.+?)?(?:(?:[-_\W](?<![)\[!]))*(?<year>(1(8|9)|20)\d{2}(?!p|i|\d+|\]|\W\d+)))+(\W+|_|$)(?!\\)", RegexOptions.IgnoreCase | RegexOptions.Compiled),
};
private static readonly Regex[] ReportMovieTitleFolderRegex = new[]
{
//When year comes first.
new Regex(@"^(?:(?:[-_\W](?<![)!]))*(?<year>(19|20)\d{2}(?!p|i|\d+|\W\d+)))+(\W+|_|$)(?<title>.+?)?$")
};
private static readonly Regex[] RejectHashedReleasesRegex = new Regex[]
{
// Generic match for md5 and mixed-case hashes.
new Regex(@"^[0-9a-zA-Z]{32}", RegexOptions.Compiled),
// Generic match for shorter lower-case hashes.
new Regex(@"^[a-z0-9]{24}$", RegexOptions.Compiled),
// Format seen on some NZBGeek releases
// Be very strict with these coz they are very close to the valid 101 ep numbering.
new Regex(@"^[A-Z]{11}\d{3}$", RegexOptions.Compiled),
new Regex(@"^[a-z]{12}\d{3}$", RegexOptions.Compiled),
//Backup filename (Unknown origins)
new Regex(@"^Backup_\d{5,}S\d{2}-\d{2}$", RegexOptions.Compiled),
//123 - Started appearing December 2014
new Regex(@"^123$", RegexOptions.Compiled),
//abc - Started appearing January 2015
new Regex(@"^abc$", RegexOptions.Compiled | RegexOptions.IgnoreCase),
//abc - Started appearing 2020
new Regex(@"^abc[-_. ]xyz", RegexOptions.Compiled | RegexOptions.IgnoreCase),
//b00bs - Started appearing January 2015
new Regex(@"^b00bs$", RegexOptions.Compiled | RegexOptions.IgnoreCase)
};
//Regex to detect whether the title was reversed.
private static readonly Regex ReversedTitleRegex = new Regex(@"(?:^|[-._ ])(p027|p0801)[-._ ]", RegexOptions.Compiled);
private static readonly Regex NormalizeRegex = new Regex(@"((?:\b|_)(?<!^|[^a-zA-Z0-9_']\w[^a-zA-Z0-9_'])(a(?!$|[^a-zA-Z0-9_']\w[^a-zA-Z0-9_'])|an|the|and|or|of)(?:\b|_))|\W|_",
RegexOptions.IgnoreCase | RegexOptions.Compiled);
private static readonly Regex FileExtensionRegex = new Regex(@"\.[a-z0-9]{2,4}$",
RegexOptions.IgnoreCase | RegexOptions.Compiled);
private static readonly Regex ReportImdbId = new Regex(@"(?<imdbid>tt\d{7,8})", RegexOptions.IgnoreCase | RegexOptions.Compiled);
private static readonly RegexReplace SimpleTitleRegex = new RegexReplace(@"\s*(?:480[ip]|576[ip]|720[ip]|1080[ip]|2160[ip]|[xh][\W_]?26[45]|DD\W?5\W1|[<>?*:|]|848x480|1280x720|1920x1080|(8|10)b(it)?)",
string.Empty,
RegexOptions.IgnoreCase | RegexOptions.Compiled);
private static readonly Regex SimpleReleaseTitleRegex = new Regex(@"\s*(?:[<>?*:|])", RegexOptions.Compiled | RegexOptions.IgnoreCase);
private static readonly RegexReplace WebsitePrefixRegex = new RegexReplace(@"^\[\s*[-a-z]+(\.[a-z]+)+\s*\][- ]*|^www\.[a-z]+\.(?:com|net|org)[ -]*",
string.Empty,
RegexOptions.IgnoreCase | RegexOptions.Compiled);
private static readonly RegexReplace WebsitePostfixRegex = new RegexReplace(@"\[\s*[-a-z]+(\.[a-z0-9]+)+\s*\]$",
string.Empty,
RegexOptions.IgnoreCase | RegexOptions.Compiled);
private static readonly RegexReplace CleanReleaseGroupRegex = new RegexReplace(@"(-(RP|1|NZBGeek|Obfuscated|Obfuscation|Scrambled|sample|Pre|postbot|xpost|Rakuv[a-z0-9]*|WhiteRev|BUYMORE|AsRequested|AlternativeToRequested|GEROV|Z0iDS3N|Chamele0n|4P|4Planet|AlteZachen))+$",
string.Empty,
RegexOptions.IgnoreCase | RegexOptions.Compiled);
private static readonly RegexReplace CleanTorrentSuffixRegex = new RegexReplace(@"\[(?:ettv|rartv|rarbg|cttv)\]$",
string.Empty,
RegexOptions.IgnoreCase | RegexOptions.Compiled);
private static readonly Regex CleanQualityBracketsRegex = new Regex(@"\[[a-z0-9 ._-]+\]$",
RegexOptions.IgnoreCase | RegexOptions.Compiled);
private static readonly Regex ReleaseGroupRegex = new Regex(@"-(?<releasegroup>[a-z0-9]+(?!.+?(?:480p|720p|1080p|2160p)))(?<!.*?WEB-DL|Blu-Ray|480p|720p|1080p|2160p|DTS-HD|DTS-X|DTS-MA|DTS-ES)(?:\b|[-._ ]|$)|[-._ ]\[(?<releasegroup>[a-z0-9]+)\]$",
RegexOptions.IgnoreCase | RegexOptions.Compiled);
private static readonly Regex AnimeReleaseGroupRegex = new Regex(@"^(?:\[(?<subgroup>(?!\s).+?(?<!\s))\](?:_|-|\s|\.)?)",
RegexOptions.IgnoreCase | RegexOptions.Compiled);
private static readonly Regex YearInTitleRegex = new Regex(@"^(?<title>.+?)(?:\W|_)?(?<year>\d{4})",
RegexOptions.IgnoreCase | RegexOptions.Compiled);
private static readonly Regex WordDelimiterRegex = new Regex(@"(\s|\.|,|_|-|=|'|\|)+", RegexOptions.Compiled);
private static readonly Regex SpecialCharRegex = new Regex(@"(\&|\:|\\|\/)+", RegexOptions.Compiled);
private static readonly Regex PunctuationRegex = new Regex(@"[^\w\s]", RegexOptions.Compiled);
private static readonly Regex CommonWordRegex = new Regex(@"\b(a|an|the|and|or|of)\b\s?", RegexOptions.IgnoreCase | RegexOptions.Compiled);
private static readonly Regex SpecialEpisodeWordRegex = new Regex(@"\b(part|special|edition|christmas)\b\s?", RegexOptions.IgnoreCase | RegexOptions.Compiled);
private static readonly Regex DuplicateSpacesRegex = new Regex(@"\s{2,}", RegexOptions.Compiled);
private static readonly Regex RequestInfoRegex = new Regex(@"^(?:\[.+?\])+", RegexOptions.Compiled);
private static readonly string[] Numbers = new[] { "zero", "one", "two", "three", "four", "five", "six", "seven", "eight", "nine" };
private static Dictionary<string, string> _umlautMappings = new Dictionary<string, string>
{
{ "ö", "oe" },
{ "ä", "ae" },
{ "ü", "ue" },
};
public static ParsedMovieInfo ParseMoviePath(string path)
{
var fileInfo = new FileInfo(path);
var result = ParseMovieTitle(fileInfo.Name, true);
if (result == null)
{
Logger.Debug("Attempting to parse movie info using directory and file names. {0}", fileInfo.Directory.Name);
result = ParseMovieTitle(fileInfo.Directory.Name + " " + fileInfo.Name);
}
if (result == null)
{
Logger.Debug("Attempting to parse movie info using directory name. {0}", fileInfo.Directory.Name);
result = ParseMovieTitle(fileInfo.Directory.Name + fileInfo.Extension);
}
return result;
}
public static ParsedMovieInfo ParseMovieTitle(string title, bool isDir = false)
{
var originalTitle = title;
try
{
if (!ValidateBeforeParsing(title))
{
return null;
}
Logger.Debug("Parsing string '{0}'", title);
if (ReversedTitleRegex.IsMatch(title))
{
var titleWithoutExtension = RemoveFileExtension(title).ToCharArray();
Array.Reverse(titleWithoutExtension);
title = new string(titleWithoutExtension) + title.Substring(titleWithoutExtension.Length);
Logger.Debug("Reversed name detected. Converted to '{0}'", title);
}
var releaseTitle = RemoveFileExtension(title);
//Trim dashes from end
releaseTitle = releaseTitle.Trim('-', '_');
releaseTitle = releaseTitle.Replace("【", "[").Replace("】", "]");
var simpleTitle = SimpleTitleRegex.Replace(releaseTitle);
// TODO: Quick fix stripping [url] - prefixes.
simpleTitle = WebsitePrefixRegex.Replace(simpleTitle);
simpleTitle = WebsitePostfixRegex.Replace(simpleTitle);
simpleTitle = CleanTorrentSuffixRegex.Replace(simpleTitle);
var allRegexes = ReportMovieTitleRegex.ToList();
if (isDir)
{
allRegexes.AddRange(ReportMovieTitleFolderRegex);
}
foreach (var regex in allRegexes)
{
var match = regex.Matches(simpleTitle);
if (match.Count != 0)
{
Logger.Trace(regex);
try
{
var result = ParseMovieMatchCollection(match);
if (result != null)
{
//TODO: Add tests for this!
var simpleReleaseTitle = SimpleReleaseTitleRegex.Replace(releaseTitle, string.Empty);
var simpleTitleReplaceString = match[0].Groups["title"].Success ? match[0].Groups["title"].Value : result.MovieTitle;
if (simpleTitleReplaceString.IsNotNullOrWhiteSpace())
{
simpleReleaseTitle = simpleReleaseTitle.Replace(simpleTitleReplaceString, simpleTitleReplaceString.Contains(".") ? "A.Movie" : "A Movie");
}
result.ReleaseGroup = ParseReleaseGroup(simpleReleaseTitle);
var subGroup = GetSubGroup(match);
if (!subGroup.IsNullOrWhiteSpace())
{
result.ReleaseGroup = subGroup;
}
Logger.Debug("Release Group parsed: {0}", result.ReleaseGroup);
result.Languages = LanguageParser.ParseLanguages(result.ReleaseGroup.IsNotNullOrWhiteSpace() ? simpleReleaseTitle.Replace(result.ReleaseGroup, "RlsGrp") : simpleReleaseTitle);
Logger.Debug("Languages parsed: {0}", string.Join(", ", result.Languages));
if (result.Edition.IsNullOrWhiteSpace())
{
result.Edition = ParseEdition(simpleReleaseTitle);
Logger.Debug("Edition parsed: {0}", result.Edition);
}
result.ReleaseHash = GetReleaseHash(match);
if (!result.ReleaseHash.IsNullOrWhiteSpace())
{
Logger.Debug("Release Hash parsed: {0}", result.ReleaseHash);
}
result.OriginalTitle = originalTitle;
result.ReleaseTitle = releaseTitle;
result.SimpleReleaseTitle = simpleReleaseTitle;
result.ImdbId = ParseImdbId(simpleReleaseTitle);
return result;
}
}
catch (InvalidDateException ex)
{
Logger.Debug(ex, ex.Message);
break;
}
}
}
}
catch (Exception e)
{
if (!title.ToLower().Contains("password") && !title.ToLower().Contains("yenc"))
{
Logger.Error(e, "An error has occurred while trying to parse {0}", title);
}
}
Logger.Debug("Unable to parse {0}", title);
return null;
}
public static string ParseImdbId(string title)
{
var match = ReportImdbId.Match(title);
if (match.Success)
{
if (match.Groups["imdbid"].Value != null)
{
if (match.Groups["imdbid"].Length == 9 || match.Groups["imdbid"].Length == 10)
{
return match.Groups["imdbid"].Value;
}
}
}
return "";
}
public static string ParseEdition(string languageTitle)
{
var editionMatch = ReportEditionRegex.Match(languageTitle);
if (editionMatch.Success && editionMatch.Groups["edition"].Value != null &&
editionMatch.Groups["edition"].Value.IsNotNullOrWhiteSpace())
{
return editionMatch.Groups["edition"].Value.Replace(".", " ");
}
return "";
}
public static string ReplaceGermanUmlauts(string s)
{
var t = s;
t = t.Replace("ä", "ae");
t = t.Replace("ö", "oe");
t = t.Replace("ü", "ue");
t = t.Replace("Ä", "Ae");
t = t.Replace("Ö", "Oe");
t = t.Replace("Ü", "Ue");
t = t.Replace("ß", "ss");
return t;
}
public static string NormalizeImdbId(string imdbId)
{
if (imdbId.Length > 2)
{
imdbId = imdbId.Replace("tt", "").PadLeft(7, '0');
return $"tt{imdbId}";
}
return null;
}
public static string ToUrlSlug(string value)
{
//First to lower case
value = value.ToLowerInvariant();
//Remove all accents
value = value.RemoveAccent();
//Replace spaces
value = Regex.Replace(value, @"\s", "-", RegexOptions.Compiled);
//Remove invalid chars
value = Regex.Replace(value, @"[^a-z0-9\s-_]", "", RegexOptions.Compiled);
//Trim dashes from end
value = value.Trim('-', '_');
//Replace double occurences of - or _
value = Regex.Replace(value, @"([-_]){2,}", "$1", RegexOptions.Compiled);
return value;
}
public static string CleanMovieTitle(this string title)
{
long number = 0;
//If Title only contains numbers return it as is.
if (long.TryParse(title, out number))
{
return title;
}
return ReplaceGermanUmlauts(NormalizeRegex.Replace(title, string.Empty).ToLower()).RemoveAccent();
}
public static string NormalizeEpisodeTitle(this string title)
{
title = SpecialEpisodeWordRegex.Replace(title, string.Empty);
title = PunctuationRegex.Replace(title, " ");
title = DuplicateSpacesRegex.Replace(title, " ");
return title.Trim()
.ToLower();
}
public static string NormalizeTitle(this string title)
{
title = WordDelimiterRegex.Replace(title, " ");
title = PunctuationRegex.Replace(title, string.Empty);
title = CommonWordRegex.Replace(title, string.Empty);
title = DuplicateSpacesRegex.Replace(title, " ");
title = SpecialCharRegex.Replace(title, string.Empty);
return title.Trim().ToLower();
}
public static string SimplifyReleaseTitle(this string title)
{
return SimpleReleaseTitleRegex.Replace(title, string.Empty);
}
public static string ParseReleaseGroup(string title)
{
title = title.Trim();
title = RemoveFileExtension(title);
title = WebsitePrefixRegex.Replace(title);
var animeMatch = AnimeReleaseGroupRegex.Match(title);
if (animeMatch.Success)
{
return animeMatch.Groups["subgroup"].Value;
}
title = CleanReleaseGroupRegex.Replace(title);
var matches = ReleaseGroupRegex.Matches(title);
if (matches.Count != 0)
{
var group = matches.OfType<Match>().Last().Groups["releasegroup"].Value;
int groupIsNumeric;
if (int.TryParse(group, out groupIsNumeric))
{
return null;
}
return group;
}
return null;
}
public static string RemoveFileExtension(string title)
{
title = FileExtensionRegex.Replace(title, m =>
{
var extension = m.Value.ToLower();
return m.Value;
});
return title;
}
private static ParsedMovieInfo ParseMovieMatchCollection(MatchCollection matchCollection)
{
if (!matchCollection[0].Groups["title"].Success || matchCollection[0].Groups["title"].Value == "(")
{
return null;
}
var movieName = matchCollection[0].Groups["title"].Value.Replace('_', ' ');
movieName = RequestInfoRegex.Replace(movieName, "").Trim(' ');
var parts = movieName.Split('.');
movieName = "";
int n = 0;
bool previousAcronym = false;
string nextPart = "";
foreach (var part in parts)
{
if (parts.Length >= n + 2)
{
nextPart = parts[n + 1];
}
else
{
nextPart = "";
}
if (part.Length == 1 && part.ToLower() != "a" && !int.TryParse(part, out _) &&
(previousAcronym || n < parts.Length - 1) &&
(previousAcronym || nextPart.Length != 1 || !int.TryParse(nextPart, out _)))
{
movieName += part + ".";
previousAcronym = true;
}
else if (part.ToLower() == "a" && (previousAcronym || nextPart.Length == 1))
{
movieName += part + ".";
previousAcronym = true;
}
else if (part.ToLower() == "dr")
{
movieName += part + ".";
previousAcronym = true;
}
else
{
if (previousAcronym)
{
movieName += " ";
previousAcronym = false;
}
movieName += part + " ";
}
n++;
}
movieName = movieName.Trim(' ');
int airYear;
int.TryParse(matchCollection[0].Groups["year"].Value, out airYear);
ParsedMovieInfo result;
result = new ParsedMovieInfo { Year = airYear };
if (matchCollection[0].Groups["edition"].Success)
{
result.Edition = matchCollection[0].Groups["edition"].Value.Replace(".", " ");
}
result.MovieTitle = movieName;
Logger.Debug("Movie Parsed. {0}", result);
return result;
}
private static bool ValidateBeforeParsing(string title)
{
if (title.ToLower().Contains("password") && title.ToLower().Contains("yenc"))
{
Logger.Debug("");
return false;
}
if (!title.Any(char.IsLetterOrDigit))
{
return false;
}
var titleWithoutExtension = RemoveFileExtension(title);
if (RejectHashedReleasesRegex.Any(v => v.IsMatch(titleWithoutExtension)))
{
Logger.Debug("Rejected Hashed Release Title: " + title);
return false;
}
return true;
}
private static string GetSubGroup(MatchCollection matchCollection)
{
var subGroup = matchCollection[0].Groups["subgroup"];
if (subGroup.Success)
{
return subGroup.Value;
}
return string.Empty;
}
private static string GetReleaseHash(MatchCollection matchCollection)
{
var hash = matchCollection[0].Groups["hash"];
if (hash.Success)
{
var hashValue = hash.Value.Trim('[', ']');
if (hashValue.Equals("1280x720"))
{
return string.Empty;
}
return hashValue;
}
return string.Empty;
}
}
}

@ -1,42 +0,0 @@
namespace NzbDrone.Core.Parser
{
public static class SceneChecker
{
//This method should prefer false negatives over false positives.
//It's better not to use a title that might be scene than to use one that isn't scene
public static string GetSceneTitle(string title)
{
if (title == null)
{
return null;
}
if (!title.Contains("."))
{
return null;
}
if (title.Contains(" "))
{
return null;
}
var parsedTitle = Parser.ParseMovieTitle(title);
if (parsedTitle == null ||
parsedTitle.ReleaseGroup == null ||
string.IsNullOrWhiteSpace(parsedTitle.MovieTitle) ||
string.IsNullOrWhiteSpace(parsedTitle.ReleaseTitle))
{
return null;
}
return parsedTitle.ReleaseTitle;
}
public static bool IsSceneTitle(string title)
{
return GetSceneTitle(title) != null;
}
}
}

@ -1,5 +1,9 @@
using System.Collections.Generic;
using Nancy;
using Nancy.ModelBinding;
using NzbDrone.Common.Extensions;
using NzbDrone.Core.Indexers;
using NzbDrone.Core.IndexerSearch;
using Prowlarr.Http.REST;
namespace Prowlarr.Api.V1.Indexers
@ -9,13 +13,19 @@ namespace Prowlarr.Api.V1.Indexers
public static readonly IndexerResourceMapper ResourceMapper = new IndexerResourceMapper();
private IIndexerFactory _indexerFactory { get; set; }
private ISearchForNzb _nzbSearchService { get; set; }
public IndexerModule(IndexerFactory indexerFactory)
public IndexerModule(IndexerFactory indexerFactory, ISearchForNzb nzbSearchService)
: base(indexerFactory, "indexer", ResourceMapper)
{
_indexerFactory = indexerFactory;
_nzbSearchService = nzbSearchService;
Get("{id}/newznab", x => GetNewznabResponse(x.id));
Get("{id}/newznab", x =>
{
var request = this.Bind<NewznabRequest>();
return GetNewznabResponse(request);
});
}
protected override void Validate(IndexerDefinition definition, bool includeWarnings)
@ -28,25 +38,36 @@ namespace Prowlarr.Api.V1.Indexers
base.Validate(definition, includeWarnings);
}
private object GetNewznabResponse(int id)
private object GetNewznabResponse(NewznabRequest request)
{
var requestType = Request.Query.t;
var requestType = request.t;
if (!requestType.HasValue)
if (requestType.IsNullOrWhiteSpace())
{
throw new BadRequestException("Missing Function Parameter");
}
if (requestType.Value == "caps")
var indexer = _indexerFactory.Get(request.id);
if (indexer == null)
{
var indexer = _indexerFactory.GetInstance(_indexerFactory.Get(id));
Response response = indexer.Capabilities.ToXml();
response.ContentType = "application/rss+xml";
return response;
throw new NotFoundException("Indexer Not Found");
}
else
var indexerInstance = _indexerFactory.GetInstance(indexer);
switch (requestType)
{
throw new BadRequestException("Function Not Available");
case "caps":
Response response = indexerInstance.GetCapabilities().ToXml();
response.ContentType = "application/rss+xml";
return response;
case "movie":
Response movieResponse = _nzbSearchService.Search(request, new List<int> { indexer.Id }, true, false).ToXml();
movieResponse.ContentType = "application/rss+xml";
return movieResponse;
default:
throw new BadRequestException("Function Not Available");
}
}
}

@ -10,10 +10,6 @@ namespace Prowlarr.Api.V1.Indexers
public bool EnableInteractiveSearch { get; set; }
public bool SupportsRss { get; set; }
public bool SupportsSearch { get; set; }
public bool SupportsMusic { get; set; }
public bool SupportsTv { get; set; }
public bool SupportsMovies { get; set; }
public bool SupportsBooks { get; set; }
public DownloadProtocol Protocol { get; set; }
public IndexerPrivacy Privacy { get; set; }
public IndexerCapabilities Capabilities { get; set; }

@ -26,17 +26,26 @@ namespace Prowlarr.Api.V1.Search
{
if (Request.Query.query.HasValue)
{
return GetSearchReleases(Request.Query.query);
var indexerIds = Request.Query.indexerIds.HasValue ? (List<int>)Request.Query.indexerIds.split(',') : new List<int>();
if (indexerIds.Count > 0)
{
return GetSearchReleases(Request.Query.query, indexerIds);
}
else
{
return GetSearchReleases(Request.Query.query, null);
}
}
return new List<SearchResource>();
}
private List<SearchResource> GetSearchReleases(string query)
private List<SearchResource> GetSearchReleases(string query, List<int> indexerIds)
{
try
{
var decisions = _nzbSearhService.MovieSearch(query, true, true);
var decisions = _nzbSearhService.Search(query, indexerIds, true, true);
return MapDecisions(decisions);
}

Loading…
Cancel
Save