20 |
54 |
55 |
60 |
61 | {% endblock %}
62 |
--------------------------------------------------------------------------------
/migrations/versions/2bceb2cb4d7c_add_comment_count_to_torrent.py:
--------------------------------------------------------------------------------
1 | """Add comment_count to Torrent
2 |
3 | Revision ID: 2bceb2cb4d7c
4 | Revises: d0eeb8049623
5 | Create Date: 2017-05-26 15:07:21.114331
6 |
7 | """
8 | from alembic import op
9 | import sqlalchemy as sa
10 |
11 |
12 | # revision identifiers, used by Alembic.
13 | revision = '2bceb2cb4d7c'
14 | down_revision = 'd0eeb8049623'
15 | branch_labels = None
16 | depends_on = None
17 |
18 | COMMENT_UPDATE_SQL = '''UPDATE {0}_torrents
19 | SET comment_count = (
20 | SELECT COUNT(*) FROM {0}_comments
21 | WHERE {0}_torrents.id = {0}_comments.torrent_id
22 | );'''
23 |
24 |
25 | def upgrade():
26 | # ### commands auto generated by Alembic - please adjust! ###
27 | op.add_column('nyaa_torrents', sa.Column('comment_count', sa.Integer(), nullable=False))
28 | op.create_index(op.f('ix_nyaa_torrents_comment_count'), 'nyaa_torrents', ['comment_count'], unique=False)
29 |
30 | op.add_column('sukebei_torrents', sa.Column('comment_count', sa.Integer(), nullable=False))
31 | op.create_index(op.f('ix_sukebei_torrents_comment_count'), 'sukebei_torrents', ['comment_count'], unique=False)
32 | # ### end Alembic commands ###
33 |
34 | connection = op.get_bind()
35 |
36 | print('Updating comment counts on nyaa_torrents...')
37 | connection.execute(sa.sql.text(COMMENT_UPDATE_SQL.format('nyaa')))
38 | print('Done.')
39 |
40 | print('Updating comment counts on sukebei_torrents...')
41 | connection.execute(sa.sql.text(COMMENT_UPDATE_SQL.format('sukebei')))
42 | print('Done.')
43 |
44 |
45 | def downgrade():
46 | # ### commands auto generated by Alembic - please adjust! ###
47 | op.drop_index(op.f('ix_nyaa_torrents_comment_count'), table_name='nyaa_torrents')
48 | op.drop_column('nyaa_torrents', 'comment_count')
49 |
50 | op.drop_index(op.f('ix_sukebei_torrents_comment_count'), table_name='sukebei_torrents')
51 | op.drop_column('sukebei_torrents', 'comment_count')
52 | # ### end Alembic commands ###
53 |
--------------------------------------------------------------------------------
/migrations/versions/7f064e009cab_add_report_table.py:
--------------------------------------------------------------------------------
1 | """Add Report table
2 |
3 | Revision ID: 7f064e009cab
4 | Revises: 2bceb2cb4d7c
5 | Create Date: 2017-05-29 16:50:28.720980
6 |
7 | """
8 | from alembic import op
9 | import sqlalchemy as sa
10 |
11 |
12 | # revision identifiers, used by Alembic.
13 | revision = '7f064e009cab'
14 | down_revision = '2bceb2cb4d7c'
15 | branch_labels = None
16 | depends_on = None
17 |
18 |
19 | def upgrade():
20 | # ### commands auto generated by Alembic - please adjust! ###
21 | op.create_table('nyaa_reports',
22 | sa.Column('id', sa.Integer(), nullable=False),
23 | sa.Column('created_time', sa.DateTime(), nullable=True),
24 | sa.Column('reason', sa.String(length=255), nullable=False),
25 |
26 | # sqlalchemy_utils.types.choice.ChoiceType()
27 | sa.Column('status', sa.Integer(), nullable=False),
28 |
29 | sa.Column('torrent_id', sa.Integer(), nullable=False),
30 | sa.Column('user_id', sa.Integer(), nullable=True),
31 | sa.ForeignKeyConstraint(['torrent_id'], ['nyaa_torrents.id'], ondelete='CASCADE'),
32 | sa.ForeignKeyConstraint(['user_id'], ['users.id'], ),
33 | sa.PrimaryKeyConstraint('id')
34 | )
35 | op.create_table('sukebei_reports',
36 | sa.Column('id', sa.Integer(), nullable=False),
37 | sa.Column('created_time', sa.DateTime(), nullable=True),
38 | sa.Column('reason', sa.String(length=255), nullable=False),
39 |
40 | # sqlalchemy_utils.types.choice.ChoiceType()
41 | sa.Column('status', sa.Integer(), nullable=False),
42 |
43 | sa.Column('torrent_id', sa.Integer(), nullable=False),
44 | sa.Column('user_id', sa.Integer(), nullable=True),
45 | sa.ForeignKeyConstraint(['torrent_id'], ['sukebei_torrents.id'], ondelete='CASCADE'),
46 | sa.ForeignKeyConstraint(['user_id'], ['users.id'], ),
47 | sa.PrimaryKeyConstraint('id')
48 | )
49 | # ### end Alembic commands ###
50 |
51 |
52 | def downgrade():
53 | # ### commands auto generated by Alembic - please adjust! ###
54 | op.drop_table('sukebei_reports')
55 | op.drop_table('nyaa_reports')
56 | # ### end Alembic commands ###
57 |
--------------------------------------------------------------------------------
/tests/test_backend.py:
--------------------------------------------------------------------------------
1 | import unittest
2 |
3 | from nyaa import backend
4 |
5 |
6 | class TestBackend(unittest.TestCase):
7 |
8 | # def setUp(self):
9 | # self.db, nyaa.app.config['DATABASE'] = tempfile.mkstemp()
10 | # nyaa.app.config['TESTING'] = True
11 | # self.app = nyaa.app.test_client()
12 | # with nyaa.app.app_context():
13 | # nyaa.db.create_all()
14 | #
15 | # def tearDown(self):
16 | # os.close(self.db)
17 | # os.unlink(nyaa.app.config['DATABASE'])
18 |
19 | def test_replace_utf8_values(self):
20 | test_dict = {
21 | 'hash': '2346ad27d7568ba9896f1b7da6b5991251debdf2',
22 | 'title.utf-8': '¡hola! ¿qué tal?',
23 | 'filelist.utf-8': [
24 | 'Español 101.mkv',
25 | 'ру́сский 202.mp4'
26 | ]
27 | }
28 | expected_dict = {
29 | 'hash': '2346ad27d7568ba9896f1b7da6b5991251debdf2',
30 | 'title': '¡hola! ¿qué tal?',
31 | 'filelist': [
32 | 'Español 101.mkv',
33 | 'ру́сский 202.mp4'
34 | ]
35 | }
36 |
37 | self.assertTrue(backend._replace_utf8_values(test_dict))
38 | self.assertDictEqual(test_dict, expected_dict)
39 |
40 | def test_replace_invalid_xml_chars(self):
41 | self.assertEqual(backend.sanitize_string('ayy\x08lmao'), 'ayy\uFFFDlmao')
42 | self.assertEqual(backend.sanitize_string('ayy\x0clmao'), 'ayy\uFFFDlmao')
43 | self.assertEqual(backend.sanitize_string('ayy\uD8FFlmao'), 'ayy\uFFFDlmao')
44 | self.assertEqual(backend.sanitize_string('ayy\uFFFElmao'), 'ayy\uFFFDlmao')
45 | self.assertEqual(backend.sanitize_string('\x08ayy\x0clmao'), '\uFFFDayy\uFFFDlmao')
46 | self.assertEqual(backend.sanitize_string('ayy\x08\x0clmao'), 'ayy\uFFFD\uFFFDlmao')
47 | self.assertEqual(backend.sanitize_string('ayy\x08\x08lmao'), 'ayy\uFFFD\uFFFDlmao')
48 | self.assertEqual(backend.sanitize_string('ぼくのぴこ'), 'ぼくのぴこ')
49 |
50 | @unittest.skip('Not yet implemented')
51 | def test_handle_torrent_upload(self):
52 | pass
53 |
54 |
55 | if __name__ == '__main__':
56 | unittest.main()
57 |
--------------------------------------------------------------------------------
/nyaa/extensions.py:
--------------------------------------------------------------------------------
1 | import os.path
2 |
3 | from flask import abort
4 | from flask.config import Config
5 | from flask_assets import Environment
6 | from flask_caching import Cache
7 | from flask_debugtoolbar import DebugToolbarExtension
8 | from flask_limiter import Limiter
9 | from flask_limiter.util import get_remote_address
10 | from flask_sqlalchemy import BaseQuery, Pagination, SQLAlchemy
11 |
12 | assets = Environment()
13 | db = SQLAlchemy()
14 | toolbar = DebugToolbarExtension()
15 | cache = Cache()
16 | limiter = Limiter(key_func=get_remote_address)
17 |
18 |
19 | class LimitedPagination(Pagination):
20 | def __init__(self, actual_count, *args, **kwargs):
21 | self.actual_count = actual_count
22 | super().__init__(*args, **kwargs)
23 |
24 |
25 | def fix_paginate():
26 |
27 | def paginate_faste(self, page=1, per_page=50, max_page=None, step=5, count_query=None):
28 | if page < 1:
29 | abort(404)
30 |
31 | if max_page and page > max_page:
32 | abort(404)
33 |
34 | # Count all items
35 | if count_query is not None:
36 | total_query_count = count_query.scalar()
37 | else:
38 | total_query_count = self.count()
39 | actual_query_count = total_query_count
40 | if max_page:
41 | total_query_count = min(total_query_count, max_page * per_page)
42 |
43 | # Grab items on current page
44 | items = self.limit(per_page).offset((page - 1) * per_page).all()
45 |
46 | if not items and page != 1:
47 | abort(404)
48 |
49 | return LimitedPagination(actual_query_count, self, page, per_page, total_query_count,
50 | items)
51 |
52 | BaseQuery.paginate_faste = paginate_faste
53 |
54 |
55 | def _get_config():
56 | # Workaround to get an available config object before the app is initiallized
57 | # Only needed/used in top-level and class statements
58 | # https://stackoverflow.com/a/18138250/7597273
59 | root_path = os.path.abspath(os.path.join(os.path.dirname(__file__), '..'))
60 | config = Config(root_path)
61 | config.from_object('config')
62 | return config
63 |
64 |
65 | config = _get_config()
66 |
--------------------------------------------------------------------------------
/migrations/versions/b61e4f6a88cc_del_torrents_info.py:
--------------------------------------------------------------------------------
1 | """Remove bencoded info dicts from mysql
2 |
3 | Revision ID: b61e4f6a88cc
4 | Revises: cf7bf6d0e6bd
5 | Create Date: 2017-08-29 01:45:08.357936
6 |
7 | """
8 | from alembic import op
9 | import sqlalchemy as sa
10 | from sqlalchemy.dialects import mysql
11 | import sys
12 |
13 | # revision identifiers, used by Alembic.
14 | revision = 'b61e4f6a88cc'
15 | down_revision = 'cf7bf6d0e6bd'
16 | branch_labels = None
17 | depends_on = None
18 |
19 |
20 | def upgrade():
21 | print("--- WARNING ---")
22 | print("This migration drops the torrent_info tables.")
23 | print("You will lose all of your .torrent files if you have not converted them beforehand.")
24 | print("Use the migration script at utils/infodict_mysql2file.py")
25 | print("Type OKAY and hit Enter to continue, CTRL-C to abort.")
26 | print("--- WARNING ---")
27 | try:
28 | if input() != "OKAY":
29 | sys.exit(1)
30 | except KeyboardInterrupt:
31 | sys.exit(1)
32 |
33 | op.drop_table('sukebei_torrents_info')
34 | op.drop_table('nyaa_torrents_info')
35 |
36 |
37 | def downgrade():
38 | op.create_table('nyaa_torrents_info',
39 | sa.Column('info_dict', mysql.MEDIUMBLOB(), nullable=True),
40 | sa.Column('torrent_id', mysql.INTEGER(display_width=11), autoincrement=False, nullable=False),
41 | sa.ForeignKeyConstraint(['torrent_id'], ['nyaa_torrents.id'], name='nyaa_torrents_info_ibfk_1', ondelete='CASCADE'),
42 | sa.PrimaryKeyConstraint('torrent_id'),
43 | mysql_collate='utf8_bin',
44 | mysql_default_charset='utf8',
45 | mysql_engine='InnoDB',
46 | mysql_row_format='COMPRESSED'
47 | )
48 | op.create_table('sukebei_torrents_info',
49 | sa.Column('info_dict', mysql.MEDIUMBLOB(), nullable=True),
50 | sa.Column('torrent_id', mysql.INTEGER(display_width=11), autoincrement=False, nullable=False),
51 | sa.ForeignKeyConstraint(['torrent_id'], ['sukebei_torrents.id'], name='sukebei_torrents_info_ibfk_1', ondelete='CASCADE'),
52 | sa.PrimaryKeyConstraint('torrent_id'),
53 | mysql_collate='utf8_bin',
54 | mysql_default_charset='utf8',
55 | mysql_engine='InnoDB',
56 | mysql_row_format='COMPRESSED'
57 | )
58 |
--------------------------------------------------------------------------------
/nyaa/templates/xmlns.html:
--------------------------------------------------------------------------------
1 | {% extends "layout.html" %}
2 | {% block title %}XML Namespace :: {{ config.SITE_NAME }}{% endblock %}
3 | {% block body %}
4 |
5 |
Nyaa XML Namespace
6 |
You found this page because our RSS feeds contain an URL that links here. Said URL is not an actual page but rather a unique identifier used to prevent name collisions with other XML namespaces.
7 |
The namespace contains the following additional, informational tags :
8 |
9 |
10 | <nyaa:seeders> holds the current amount of seeders on the respective torrent.
11 |
12 |
13 | <nyaa:leechers> holds the current amount of leechers on the respective torrent.
14 |
15 |
16 | <nyaa:downloads> counts the downloads the torrent got up to the point the feed was refreshed.
17 |
18 |
19 | <nyaa:infoHash> is the torrent's infohash, a unique identifier, in hexadecimal.
20 |
21 |
22 | <nyaa:categoryId> contains the ID of the category containing the upload in the form category_subcategory.
23 |
24 |
25 | <nyaa:category> contains the written name of the torrent's category in the form Category - Subcategory.
26 |
27 |
28 | <nyaa:size> indicates the torrent's download size to one decimal place, using a magnitude prefix according to ISO/IEC 80000-13.
29 |
30 |
31 | <nyaa:trusted> indicates whether the torrent came from a trusted uploader (YES or NO).
32 |
33 |
34 | <nyaa:remake> indicates whether the torrent was a remake (YES or NO).
35 |
36 |
37 | <nyaa:comments> holds the current amount of comments made on the respective torrent.
38 |
39 |
40 |
41 | {% endblock %}
42 |
--------------------------------------------------------------------------------
/nyaa/templates/reports.html:
--------------------------------------------------------------------------------
1 | {% extends "layout.html" %}
2 | {% block title %}Reports :: {{ config.SITE_NAME }}{% endblock %}
3 | {% block body %}
4 | {% from "_formhelpers.html" import render_field %}
5 |
6 |
7 |
8 |
9 | #
10 | Reported by
11 | Torrent
12 | Reason
13 | Date
14 | Action
15 |
16 |
17 |
18 | {% for report in reports.items %}
19 |
20 | {{ report.id }}
21 |
22 | {{ report.user.username }}
23 | {% if report.user.is_trusted %}
24 | Trusted
25 | {% endif %}
26 |
27 |
28 | {{ report.torrent.display_name }}
29 | by
30 | {{ report.torrent.user.username }}
31 | {% if g.user.is_superadmin and report.torrent.uploader_ip %}
32 | ({{ report.torrent.uploader_ip_string }})
33 | {% endif %}
34 | {% if report.torrent.user.is_trusted %}
35 | Trusted
36 | {% endif %}
37 |
38 | {{ report.reason }}
39 | {{ report.created_time }}
40 |
41 |
52 |
53 |
54 | {% endfor %}
55 |
56 |
57 |
58 |
59 |
63 | {% endblock %}
64 |
--------------------------------------------------------------------------------
/nyaa/templates/bootstrap/pagination.html:
--------------------------------------------------------------------------------
1 | ## https://github.com/mbr/flask-bootstrap/blob/master/flask_bootstrap/templates/bootstrap/pagination.html
2 | {% macro _arg_url_for(endpoint, base) %}
3 | {# calls url_for() with a given endpoint and **base as the parameters,
4 | additionally passing on all keyword_arguments (may overwrite existing ones)
5 | #}
6 | {%- with kargs = base.copy() -%}
7 | {%- do kargs.update(kwargs) -%}
8 | {{url_for(endpoint, **kargs)}}
9 | {%- endwith %}
10 | {%- endmacro %}
11 |
12 | {% macro render_pagination(pagination,
13 | endpoint=None,
14 | prev=('«')|safe,
15 | next=('»')|safe,
16 | size=None,
17 | ellipses='…',
18 | args={}
19 | )
20 | -%}
21 | {% with url_args = {} %}
22 | {%- do url_args.update(request.view_args if not endpoint else {}),
23 | url_args.update(request.args if not endpoint else {}),
24 | url_args.update(args) -%}
25 | {% with endpoint = endpoint or request.endpoint %}
26 |
27 |
57 |
58 | {% endwith %}
59 | {% endwith %}
60 | {% endmacro %}
61 |
--------------------------------------------------------------------------------
/db_create.py:
--------------------------------------------------------------------------------
1 | #!/usr/bin/env python3
2 | import sqlalchemy
3 |
4 | from nyaa import create_app, models
5 | from nyaa.extensions import db
6 |
7 | app = create_app('config')
8 |
9 | NYAA_CATEGORIES = [
10 | ('Anime', ['Anime Music Video', 'English-translated', 'Non-English-translated', 'Raw']),
11 | ('Audio', ['Lossless', 'Lossy']),
12 | ('Literature', ['English-translated', 'Non-English-translated', 'Raw']),
13 | ('Live Action', ['English-translated', 'Idol/Promotional Video', 'Non-English-translated', 'Raw']),
14 | ('Pictures', ['Graphics', 'Photos']),
15 | ('Software', ['Applications', 'Games']),
16 | ]
17 |
18 |
19 | SUKEBEI_CATEGORIES = [
20 | ('Art', ['Anime', 'Doujinshi', 'Games', 'Manga', 'Pictures']),
21 | ('Real Life', ['Photobooks / Pictures', 'Videos']),
22 | ]
23 |
24 |
25 | def add_categories(categories, main_class, sub_class):
26 | for main_cat_name, sub_cat_names in categories:
27 | main_cat = main_class(name=main_cat_name)
28 | for i, sub_cat_name in enumerate(sub_cat_names):
29 | # Composite keys can't autoincrement, set sub_cat id manually (1-index)
30 | sub_cat = sub_class(id=i+1, name=sub_cat_name, main_category=main_cat)
31 | db.session.add(main_cat)
32 |
33 |
34 | if __name__ == '__main__':
35 | with app.app_context():
36 | # Test for the user table, assume db is empty if it's not created
37 | database_empty = False
38 | try:
39 | models.User.query.first()
40 | except (sqlalchemy.exc.ProgrammingError, sqlalchemy.exc.OperationalError):
41 | database_empty = True
42 |
43 | print('Creating all tables...')
44 | db.create_all()
45 |
46 | nyaa_category_test = models.NyaaMainCategory.query.first()
47 | if not nyaa_category_test:
48 | print('Adding Nyaa categories...')
49 | add_categories(NYAA_CATEGORIES, models.NyaaMainCategory, models.NyaaSubCategory)
50 |
51 | sukebei_category_test = models.SukebeiMainCategory.query.first()
52 | if not sukebei_category_test:
53 | print('Adding Sukebei categories...')
54 | add_categories(SUKEBEI_CATEGORIES, models.SukebeiMainCategory, models.SukebeiSubCategory)
55 |
56 | db.session.commit()
57 |
58 | if database_empty:
59 | print('Remember to run the following to mark the database up-to-date for Alembic:')
60 | print('./db_migrate.py stamp head')
61 | # Technically we should be able to do this here, but when you have
62 | # Flask-Migrate and Flask-SQA and everything... I didn't get it working.
63 |
--------------------------------------------------------------------------------
/nyaa/static/pinned-tab.svg:
--------------------------------------------------------------------------------
1 |
2 |
7 |
8 |
--------------------------------------------------------------------------------
/nyaa/email.py:
--------------------------------------------------------------------------------
1 | import smtplib
2 | from email.mime.multipart import MIMEMultipart
3 | from email.mime.text import MIMEText
4 |
5 | from flask import current_app as app
6 |
7 | import requests
8 |
9 | from nyaa import models
10 |
11 |
12 | class EmailHolder(object):
13 | ''' Holds email subject, recipient and content, so we have a general class for
14 | all mail backends. '''
15 |
16 | def __init__(self, subject=None, recipient=None, text=None, html=None):
17 | self.subject = subject
18 | self.recipient = recipient # models.User or string
19 | self.text = text
20 | self.html = html
21 |
22 | def format_recipient(self):
23 | if isinstance(self.recipient, models.User):
24 | return '{} <{}>'.format(self.recipient.username, self.recipient.email)
25 | else:
26 | return self.recipient
27 |
28 | def recipient_email(self):
29 | if isinstance(self.recipient, models.User):
30 | return self.recipient.email
31 | else:
32 | return self.recipient.email
33 |
34 | def as_mimemultipart(self):
35 | msg = MIMEMultipart()
36 | msg['Subject'] = self.subject
37 | msg['From'] = app.config['MAIL_FROM_ADDRESS']
38 | msg['To'] = self.format_recipient()
39 |
40 | msg.attach(MIMEText(self.text, 'plain'))
41 | if self.html:
42 | msg.attach(MIMEText(self.html, 'html'))
43 |
44 | return msg
45 |
46 |
47 | def send_email(email_holder):
48 | mail_backend = app.config.get('MAIL_BACKEND')
49 | if mail_backend == 'mailgun':
50 | _send_mailgun(email_holder)
51 | elif mail_backend == 'smtp':
52 | _send_smtp(email_holder)
53 | elif mail_backend:
54 | # TODO: Do this in logging.error when we have that set up
55 | print('Unknown mail backend:', mail_backend)
56 |
57 |
58 | def _send_mailgun(email_holder):
59 | mailgun_endpoint = app.config['MAILGUN_API_BASE'] + '/messages'
60 | auth = ('api', app.config['MAILGUN_API_KEY'])
61 | data = {
62 | 'from': app.config['MAIL_FROM_ADDRESS'],
63 | 'to': email_holder.format_recipient(),
64 | 'subject': email_holder.subject,
65 | 'text': email_holder.text,
66 | 'html': email_holder.html
67 | }
68 | r = requests.post(mailgun_endpoint, data=data, auth=auth)
69 | # TODO real error handling?
70 | assert r.status_code == 200
71 |
72 |
73 | def _send_smtp(email_holder):
74 | # NOTE: Unused, most likely untested! Should work, however.
75 | msg = email_holder.as_mimemultipart()
76 |
77 | server = smtplib.SMTP(app.config['SMTP_SERVER'], app.config['SMTP_PORT'])
78 | server.set_debuglevel(1)
79 | server.ehlo()
80 | server.starttls()
81 | server.ehlo()
82 | server.login(app.config['SMTP_USERNAME'], app.config['SMTP_PASSWORD'])
83 | server.sendmail(app.config['SMTP_USERNAME'], email_holder.recipient_email(), msg.as_string())
84 | server.quit()
85 |
--------------------------------------------------------------------------------
/nyaa/utils.py:
--------------------------------------------------------------------------------
1 | import functools
2 | import hashlib
3 | import random
4 | import string
5 | from collections import OrderedDict
6 |
7 | import flask
8 |
9 |
10 | def sha1_hash(input_bytes):
11 | """ Hash given bytes with hashlib.sha1 and return the digest (as bytes) """
12 | return hashlib.sha1(input_bytes).digest()
13 |
14 |
15 | def sorted_pathdict(input_dict):
16 | """ Sorts a parsed torrent filelist dict by alphabat, directories first """
17 | directories = OrderedDict()
18 | files = OrderedDict()
19 |
20 | for key, value in input_dict.items():
21 | if isinstance(value, dict):
22 | directories[key] = sorted_pathdict(value)
23 | else:
24 | files[key] = value
25 |
26 | return OrderedDict(sorted(directories.items()) + sorted(files.items()))
27 |
28 |
29 | def random_string(length, charset=None):
30 | if charset is None:
31 | charset = string.ascii_letters + string.digits
32 | return ''.join(random.choice(charset) for i in range(length))
33 |
34 |
35 | def cached_function(f):
36 | sentinel = object()
37 | f._cached_value = sentinel
38 |
39 | @functools.wraps(f)
40 | def decorator(*args, **kwargs):
41 | if f._cached_value is sentinel:
42 | f._cached_value = f(*args, **kwargs)
43 | return f._cached_value
44 | return decorator
45 |
46 |
47 | def flatten_dict(d, result=None):
48 | if result is None:
49 | result = {}
50 | for key in d:
51 | value = d[key]
52 | if isinstance(value, dict):
53 | value1 = {}
54 | for keyIn in value:
55 | value1["/".join([key, keyIn])] = value[keyIn]
56 | flatten_dict(value1, result)
57 | elif isinstance(value, (list, tuple)):
58 | for indexB, element in enumerate(value):
59 | if isinstance(element, dict):
60 | value1 = {}
61 | index = 0
62 | for keyIn in element:
63 | newkey = "/".join([key, keyIn])
64 | value1[newkey] = value[indexB][keyIn]
65 | index += 1
66 | for keyA in value1:
67 | flatten_dict(value1, result)
68 | else:
69 | result[key] = value
70 | return result
71 |
72 |
73 | def chain_get(source, *args):
74 | ''' Tries to return values from source by the given keys.
75 | Returns None if none match.
76 | Note: can return a None from the source. '''
77 | sentinel = object()
78 | for key in args:
79 | value = source.get(key, sentinel)
80 | if value is not sentinel:
81 | return value
82 | return None
83 |
84 |
85 | def admin_only(f):
86 | @functools.wraps(f)
87 | def wrapper(*args, **kwargs):
88 | if flask.g.user and flask.g.user.is_superadmin:
89 | return f(*args, **kwargs)
90 | else:
91 | flask.abort(401)
92 | return wrapper
93 |
--------------------------------------------------------------------------------
/migrations/env.py:
--------------------------------------------------------------------------------
1 | from __future__ import with_statement
2 | from alembic import context
3 | from sqlalchemy import engine_from_config, pool
4 | from logging.config import fileConfig
5 | import logging
6 |
7 | # this is the Alembic Config object, which provides
8 | # access to the values within the .ini file in use.
9 | config = context.config
10 |
11 | # Interpret the config file for Python logging.
12 | # This line sets up loggers basically.
13 | fileConfig(config.config_file_name)
14 | logger = logging.getLogger('alembic.env')
15 |
16 | # add your model's MetaData object here
17 | # for 'autogenerate' support
18 | # from myapp import mymodel
19 | # target_metadata = mymodel.Base.metadata
20 | from flask import current_app
21 | config.set_main_option('sqlalchemy.url',
22 | current_app.config.get('SQLALCHEMY_DATABASE_URI'))
23 | target_metadata = current_app.extensions['migrate'].db.metadata
24 |
25 | # other values from the config, defined by the needs of env.py,
26 | # can be acquired:
27 | # my_important_option = config.get_main_option("my_important_option")
28 | # ... etc.
29 |
30 |
31 | def run_migrations_offline():
32 | """Run migrations in 'offline' mode.
33 |
34 | This configures the context with just a URL
35 | and not an Engine, though an Engine is acceptable
36 | here as well. By skipping the Engine creation
37 | we don't even need a DBAPI to be available.
38 |
39 | Calls to context.execute() here emit the given string to the
40 | script output.
41 |
42 | """
43 | url = config.get_main_option("sqlalchemy.url")
44 | context.configure(url=url)
45 |
46 | with context.begin_transaction():
47 | context.run_migrations()
48 |
49 |
50 | def run_migrations_online():
51 | """Run migrations in 'online' mode.
52 |
53 | In this scenario we need to create an Engine
54 | and associate a connection with the context.
55 |
56 | """
57 |
58 | # this callback is used to prevent an auto-migration from being generated
59 | # when there are no changes to the schema
60 | # reference: http://alembic.readthedocs.org/en/latest/cookbook.html
61 | def process_revision_directives(context, revision, directives):
62 | if getattr(config.cmd_opts, 'autogenerate', False):
63 | script = directives[0]
64 | if script.upgrade_ops.is_empty():
65 | directives[:] = []
66 | logger.info('No changes in schema detected.')
67 |
68 | engine = engine_from_config(config.get_section(config.config_ini_section),
69 | prefix='sqlalchemy.',
70 | poolclass=pool.NullPool)
71 |
72 | connection = engine.connect()
73 | context.configure(connection=connection,
74 | target_metadata=target_metadata,
75 | process_revision_directives=process_revision_directives,
76 | **current_app.extensions['migrate'].configure_args)
77 |
78 | try:
79 | with context.begin_transaction():
80 | context.run_migrations()
81 | finally:
82 | connection.close()
83 |
84 | if context.is_offline_mode():
85 | run_migrations_offline()
86 | else:
87 | run_migrations_online()
88 |
--------------------------------------------------------------------------------
/nyaa/templates/rss.xml:
--------------------------------------------------------------------------------
1 |
2 |
3 | {{ config.SITE_NAME }} - {{ term }} - {% if not magnet_links %}Torrent File{% else %}Magnet URI{% endif %} RSS
4 | RSS Feed for {{ term }}
5 | {{ url_for('main.home', _external=True) }}
6 |
7 | {% for torrent in torrent_query %}
8 | -
9 |
{{ torrent.display_name }}
10 | {% if use_elastic %}
11 | {# ElasticSearch Torrent instances #}
12 | {% if torrent.has_torrent and not magnet_links %}
13 | {{ url_for('torrents.download', torrent_id=torrent.meta.id, _external=True) }}
14 | {% else %}
15 | {{ create_magnet_from_es_torrent(torrent) }}
16 | {% endif %}
17 | {{ url_for('torrents.view', torrent_id=torrent.meta.id, _external=True) }}
18 | {{ torrent.created_time|rfc822_es }}
19 |
20 | {{- torrent.seed_count }}
21 | {{- torrent.leech_count }}
22 | {{- torrent.download_count }}
23 | {{- torrent.info_hash }}
24 | {% else %}
25 | {# Database Torrent rows #}
26 | {% if torrent.has_torrent and not magnet_links %}
27 | {{ url_for('torrents.download', torrent_id=torrent.id, _external=True) }}
28 | {% else %}
29 | {{ torrent.magnet_uri }}
30 | {% endif %}
31 | {{ url_for('torrents.view', torrent_id=torrent.id, _external=True) }}
32 | {{ torrent.created_time|rfc822 }}
33 |
34 | {{- torrent.stats.seed_count }}
35 | {{- torrent.stats.leech_count }}
36 | {{- torrent.stats.download_count }}
37 | {{- torrent.info_hash_as_hex }}
38 | {% endif %}
39 | {% set cat_id = use_elastic and ((torrent.main_category_id|string) + '_' + (torrent.sub_category_id|string)) or torrent.sub_category.id_as_string %}
40 | {{- cat_id }}
41 | {{- category_name(cat_id) }}
42 | {{- torrent.filesize | filesizeformat(True) }}
43 | {{- torrent.comment_count }}
44 | {{- torrent.trusted and 'Yes' or 'No' }}
45 | {{- torrent.remake and 'Yes' or 'No' }}
46 | {% set torrent_id = use_elastic and torrent.meta.id or torrent.id %}
47 | #{{ torrent_id }} | {{ torrent.display_name }} | {{ torrent.filesize | filesizeformat(True) }} | {{ category_name(cat_id) }} | {{ use_elastic and torrent.info_hash or torrent.info_hash_as_hex | upper }}]]>
48 |
49 | {% endfor %}
50 |
51 |
52 |
--------------------------------------------------------------------------------
/nyaa/static/search.xml:
--------------------------------------------------------------------------------
1 |
2 | Nyaa.si
3 |
4 | Search torrents on Nyaa.si.
5 | UTF-8
6 | data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAACAAAAAgCAYAAABzenr0AAAHmElEQVRYw8WXXYxdVRXHf2vvc849537MvTOdmQ4zUygtpS0FIWL4iBEE9cGqAU0gojHBxAef9MFETXwhRmM00ReNia8kmhh9g0R8kISYiFjAKqYNhZa2Qzud7897zz0fey8fzqVzoS2UJ3Zyc25y99nrt/7rv9feV+J7v6t8hCMAmHn4B+BK1nrwYWmSmtDuGIoSapGhfnSCe6Ys+9KMn/xq8Yr5cSz0+1WU9NgvK4BW3XJhuQThQxFMjVtanZBGM4SmoTvbpD+XYls1FnslYQA2EAiUVkPo3JbQfrCBnU9ZfzXn+LGBAllWUrrrDxxa2LuvQdauI6Js1UJIDJIbwpplxAhrnZgbn9xNs2NwiWAbBp8IpVHKXZbG7SX8dgDQqMFo3bDW8x8YfM+UpdjVYS6pEwfQMxAZCEuwfUgbMXXxrDuPaUf0IwgC8B6kAELBGoPB7Hjg9IJjdswQRZZ+rqS5p3BVpkkEtVpAGFuKZsJc2CKwkCiIDqrmwQuYHKwFV8BiKRCAsaAGVEBM9d2LQcTuALRCh8tz+hoTJyGmVWMxs2RBwGoY4YxBQogUYgXjoXTgqT4CiICUEGTw13khSwzSALWAqZ7qgBJUBB8MKTBvRin6FqzFZh7XD8EKlAIi2AhCD1arwIVCU5SOZiR5l17cwqihIKDplLQniAEJwJuBCgMlGKigZgigdLZKQRXnDYgfvKngwJcBTqAsoKGOvfTYfnuT5dTTbAjx4RG6GtARz2SoXOhbbAAagNjqWUk/kGsAchlAnUKo1SwHhO8U1gCKlp5CDXtGCqZ7K5y6WFw2ZS+FJkKA56Fpz2JmOdODpF+t7mylgBjIBUYtZMVAjcscrsoU0aqojqohlIAqOE+Nkhm3wen54t2NKIS6lDx8g9J1hhcXBAP4HPpdaBSK5Mp6Hx4IlMdrnrQAimEAtIJQrWyVQyyeelBCZpiqO+6MNzl9PsW9p1GluRI6z9ltwzNnDVLx0i/hwY5jv3VMqfJo3fOzScidkBXg3VAJqmwFFEwN9rS61Cws5BF3tdfJu543Nq7eIrMS+r0SXxciFK9C5uBLN3oS4zHe8PUZ5eHdlkLhlW1PWyAvZQegYTNGGpZdsedcGrNZBFgXMKGbnFv44BZ5aKRkcsLxr0V4o2uZbSqlQj0Qvn1QWcmF1Ux5etETldAMhI1Cd0owYjMaps9aatnqwlrPsr/eZWX7/YPXY8ON0zUO70m4tFmSltWhdKjtmQgLasbzn0VHwyp/nvMciaGXw1wq+GEPNGsxvTJkslUymRQ8MLPNqYX8ioAiEBolsp5WIgQGfFZwcFfAha7lzDp8dsbzi/uUvIA/vBXx1PGI584r97cVxfDCsmWvKr8eHwIwpIjPiQKYTDJee7u8asaqUHghd4Y0U/JCKb3y6rzn2GLAZKx87QDsblpeW7e8vqp8YgLun4SXl+DHx+GxMeXpfbAnHDLhcs8w3jSsbjuWtz645hMjljCArZ5jeQNeX/aIwsGOcs8NBiPC7x4yPHfOsTvxzLYCnp2Db92kPHlAWM6VUnUHYLSubKRKVvrrCB4wMRpxaq5H6aEeCf84H9DzsK8tTDUqd9/QNHzziMGrcnxJeeIWODxmcF4ZCYVTm24HYKX7/jeROBScVwoHzhWsrHlKD9bA7M2jvLQaIiE8ckCueLdfKIdGhXpY/WaNYIEXN+1wH7j2aETCSN2ytFn5opsJogVguWNvwptbwkSUgRVaYTBo9kM7JTJXrFk65fiyDnfCaw/nlW6/5J3qVF3b0qkLa0XEnihjNspoaMbf5+T6LqJW+NEhuT6AYlDnHXooHExPRiRlj0whzlPGJWNtu7jqGl5hK1eOL/nLhb4wUPQ6FIB6zTI5sjO1HQvqISs8DfG8tVDQxHFkQvjnhSrEySXPwrbn3IZHFeqhsK9tLhfo8Nj7eCC0VZYAVpSVLcdUJ2ArrU6yZmzppg7n4OJSykhiuGUm4ZM3BTz1glK3JRsZnN1QujlcGLHcOmYYr8tlRU6tm6sDPPbxmEfvbnLsbI/nT+Rc2ihBYTNVJKixd6TERoZurvRKQ2c04ct3Nbht2jDdEvY0PeupMtGwjDdgqatsZMM7TOkXjmfPypUABycN3z/aZv/uiM9/LOGHR5U/Hsv4+V9S5rc9G7nlwmaMJPC5Wy37d1mO3hHQ7Xs+s99wft3znXuFzFmWujAaDS6tKjSjyk8vX3T86d89/rv6HgWSEJ64r8H+3dFloCgQvnF/zBfujPj9Sxm3zwScnHecWHT85rE6AG8ue0pf1TMrlHbHsrDlOTAunF4uiULDagrfe95xcs2QnVvFW2C8tWPCqRgkF75yd/OqRhyrGw5NWUILzRr89IsJzisgWPGERlnteg5MVCC7WwYRYbptSQs4s+7Z6pasn7rExZWCotPgHPEOwMmTBZ86XGN27Np9aXmz5G8nMh65s0Y7MdjBrTYtDP+bd9y8693vnlhwPHOyYCyGzQwevxXeXi7JSiX1AQe0P7wNDV+9t3HN4KpwftXzxD0x7USG7AR54fn0LSF2aEOfWfG8PFdyZNKSO5huCAcmQ+o1w1hD6GPIL21UR/xH/ff8/7zueff8JH+eAAAAAElFTkSuQmCC
7 |
8 |
9 |
10 |
11 |
12 |
13 | https://nyaa.si/
14 |
--------------------------------------------------------------------------------
/nyaa/static/search-sukebei.xml:
--------------------------------------------------------------------------------
1 |
2 | Sukebei (Nyaa.si)
3 |
4 | Search torrents on Sukebei (Nyaa.si).
5 | UTF-8
6 | data:image/png;base64,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
7 |
8 |
9 |
10 |
11 |
12 |
13 | https://sukebei.nyaa.si/
14 |
15 |
--------------------------------------------------------------------------------
/nyaa/templates/user_comments.html:
--------------------------------------------------------------------------------
1 | {% extends "layout.html" %}
2 | {% block title %}Comments made by {{ user.username }} :: {{ config.SITE_NAME }}{% endblock %}
3 | {% block meta_image %}{{ user.gravatar_url() }}{% endblock %}
4 | {% block metatags %}
5 |
6 | {% endblock %}
7 |
8 | {% block body %}
9 | {% from "_formhelpers.html" import render_menu_with_button %}
10 | {% from "_formhelpers.html" import render_field %}
11 |
12 |
13 | {{ user.username }} '{{ '' if user.username[-1] == 's' else 's' }} comments
14 |
15 |
16 | {% if comments_query.items %}
17 |
60 |
61 | {% else %}
62 |
No comments
63 | {% endif %}
64 |
65 |
66 | {% from "bootstrap/pagination.html" import render_pagination %}
67 | {{ render_pagination(comments_query) }}
68 |
69 |
70 |
71 | {% endblock %}
72 |
73 |
--------------------------------------------------------------------------------
/tests/test_template_utils.py:
--------------------------------------------------------------------------------
1 | import unittest
2 | import datetime
3 |
4 | from email.utils import formatdate
5 |
6 | from tests import NyaaTestCase
7 | from nyaa.template_utils import (_jinja2_filter_rfc822, _jinja2_filter_rfc822_es, get_utc_timestamp,
8 | get_display_time, timesince, filter_truthy, category_name)
9 |
10 |
11 | class TestTemplateUtils(NyaaTestCase):
12 |
13 | # def setUp(self):
14 | # self.db, nyaa.app.config['DATABASE'] = tempfile.mkstemp()
15 | # nyaa.app.config['TESTING'] = True
16 | # self.app = nyaa.app.test_client()
17 | # with nyaa.app.app_context():
18 | # nyaa.db.create_all()
19 | #
20 | # def tearDown(self):
21 | # os.close(self.db)
22 | # os.unlink(nyaa.app.config['DATABASE'])
23 |
24 | def test_filter_rfc822(self):
25 | # test with timezone UTC
26 | test_date = datetime.datetime(2017, 2, 15, 11, 15, 34, 100, datetime.timezone.utc)
27 | self.assertEqual(_jinja2_filter_rfc822(test_date), 'Wed, 15 Feb 2017 11:15:34 -0000')
28 |
29 | def test_filter_rfc822_es(self):
30 | # test with local timezone
31 | test_date_str = '2017-02-15T11:15:34'
32 | # this is in order to get around local time zone issues
33 | expected = formatdate(float(datetime.datetime(2017, 2, 15, 11, 15, 34, 100).timestamp()))
34 | self.assertEqual(_jinja2_filter_rfc822_es(test_date_str), expected)
35 |
36 | def test_get_utc_timestamp(self):
37 | # test with local timezone
38 | test_date_str = '2017-02-15T11:15:34'
39 | self.assertEqual(get_utc_timestamp(test_date_str), 1487157334)
40 |
41 | def test_get_display_time(self):
42 | # test with local timezone
43 | test_date_str = '2017-02-15T11:15:34'
44 | self.assertEqual(get_display_time(test_date_str), '2017-02-15 11:15')
45 |
46 | def test_timesince(self):
47 | now = datetime.datetime.utcnow()
48 | self.assertEqual(timesince(now), 'just now')
49 | self.assertEqual(timesince(now - datetime.timedelta(seconds=5)), '5 seconds ago')
50 | self.assertEqual(timesince(now - datetime.timedelta(minutes=1)), '1 minute ago')
51 | self.assertEqual(
52 | timesince(now - datetime.timedelta(minutes=38, seconds=43)), '38 minutes ago')
53 | self.assertEqual(
54 | timesince(now - datetime.timedelta(hours=2, minutes=38, seconds=51)), '2 hours ago')
55 | bigger = now - datetime.timedelta(days=3)
56 | self.assertEqual(timesince(bigger), bigger.strftime('%Y-%m-%d %H:%M UTC'))
57 |
58 | @unittest.skip('Not yet implemented')
59 | def test_static_cachebuster(self):
60 | pass
61 |
62 | @unittest.skip('Not yet implemented')
63 | def test_modify_query(self):
64 | pass
65 |
66 | def test_filter_truthy(self):
67 | my_list = [
68 | True, False, # booleans
69 | 'hello!', '', # strings
70 | 1, 0, -1, # integers
71 | 1.0, 0.0, -1.0, # floats
72 | ['test'], [], # lists
73 | {'marco': 'polo'}, {}, # dictionaries
74 | None
75 | ]
76 | expected_result = [
77 | True,
78 | 'hello!',
79 | 1, -1,
80 | 1.0, -1.0,
81 | ['test'],
82 | {'marco': 'polo'}
83 | ]
84 | self.assertListEqual(filter_truthy(my_list), expected_result)
85 |
86 | def test_category_name(self):
87 | with self.app_context:
88 | # Nyaa categories only
89 | self.assertEqual(category_name('1_0'), 'Anime')
90 | self.assertEqual(category_name('1_2'), 'Anime - English-translated')
91 | # Unknown category ids
92 | self.assertEqual(category_name('100_0'), '???')
93 | self.assertEqual(category_name('1_100'), '???')
94 | self.assertEqual(category_name('0_0'), '???')
95 |
96 |
97 | if __name__ == '__main__':
98 | unittest.main()
99 |
--------------------------------------------------------------------------------
/tests/test_utils.py:
--------------------------------------------------------------------------------
1 | import unittest
2 | from collections import OrderedDict
3 |
4 | from hashlib import sha1
5 | from nyaa import utils
6 |
7 |
8 | class TestUtils(unittest.TestCase):
9 |
10 | def test_sha1_hash(self):
11 | bencoded_test_data = b'd5:hello5:world7:numbersli1ei2eee'
12 | self.assertEqual(
13 | utils.sha1_hash(bencoded_test_data),
14 | sha1(bencoded_test_data).digest())
15 |
16 | def test_sorted_pathdict(self):
17 | initial = {
18 | 'api_handler.py': 11805,
19 | 'routes.py': 34247,
20 | '__init__.py': 6499,
21 | 'torrents.py': 11948,
22 | 'static': {
23 | 'img': {
24 | 'nyaa.png': 1200,
25 | 'sukebei.png': 1100,
26 | },
27 | 'js': {
28 | 'main.js': 3000,
29 | },
30 | },
31 | 'search.py': 5148,
32 | 'models.py': 24293,
33 | 'templates': {
34 | 'upload.html': 3000,
35 | 'home.html': 1200,
36 | 'layout.html': 23000,
37 | },
38 | 'utils.py': 14700,
39 | }
40 | expected = OrderedDict({
41 | 'static': OrderedDict({
42 | 'img': OrderedDict({
43 | 'nyaa.png': 1200,
44 | 'sukebei.png': 1100,
45 | }),
46 | 'js': OrderedDict({
47 | 'main.js': 3000,
48 | }),
49 | }),
50 | 'templates': OrderedDict({
51 | 'home.html': 1200,
52 | 'layout.html': 23000,
53 | 'upload.html': 3000,
54 | }),
55 | '__init__.py': 6499,
56 | 'api_handler.py': 11805,
57 | 'models.py': 24293,
58 | 'routes.py': 34247,
59 | 'search.py': 5148,
60 | 'torrents.py': 11948,
61 | 'utils.py': 14700,
62 | })
63 | self.assertDictEqual(utils.sorted_pathdict(initial), expected)
64 |
65 | @unittest.skip('Not yet implemented')
66 | def test_cached_function(self):
67 | # TODO: Test with a function that generates something random?
68 | pass
69 |
70 | def test_flatten_dict(self):
71 | initial = OrderedDict({
72 | 'static': OrderedDict({
73 | 'img': OrderedDict({
74 | 'nyaa.png': 1200,
75 | 'sukebei.png': 1100,
76 | }),
77 | 'js': OrderedDict({
78 | 'main.js': 3000,
79 | }),
80 | 'favicon.ico': 1000,
81 | }),
82 | 'templates': [
83 | {'home.html': 1200},
84 | {'layout.html': 23000},
85 | {'upload.html': 3000},
86 | ],
87 | '__init__.py': 6499,
88 | 'api_handler.py': 11805,
89 | 'models.py': 24293,
90 | 'routes.py': 34247,
91 | 'search.py': 5148,
92 | 'torrents.py': 11948,
93 | 'utils.py': 14700,
94 | })
95 | expected = {
96 | 'static/img/nyaa.png': 1200,
97 | 'static/img/sukebei.png': 1100,
98 | 'static/js/main.js': 3000,
99 | 'static/favicon.ico': 1000,
100 | 'templates/home.html': 1200,
101 | 'templates/layout.html': 23000,
102 | 'templates/upload.html': 3000,
103 | '__init__.py': 6499,
104 | 'api_handler.py': 11805,
105 | 'models.py': 24293,
106 | 'routes.py': 34247,
107 | 'search.py': 5148,
108 | 'utils.py': 14700,
109 | 'torrents.py': 11948,
110 | }
111 | self.assertDictEqual(utils.flatten_dict(initial), expected)
112 |
113 |
114 | if __name__ == '__main__':
115 | unittest.main()
116 |
--------------------------------------------------------------------------------
/nyaa/templates/admin_trusted_view.html:
--------------------------------------------------------------------------------
1 | {% extends "layout.html" %}
2 | {% from "_formhelpers.html" import render_field, render_menu_with_button %}
3 | {%- macro review_class(rec) -%}
4 | {%- if rec.name == 'ACCEPT' -%}
5 | {{ 'panel-success' -}}
6 | {%- elif rec.name == 'REJECT' -%}
7 | {{ 'panel-danger' -}}
8 | {%- elif rec.name == 'ABSTAIN' -%}
9 | {{ 'panel-default' -}}
10 | {%- endif -%}
11 | {%- endmacro -%}
12 | {% block title %}{{ app.submitter.username }}'s Application :: {{ config.SITE_NAME }}{% endblock %}
13 | {% block body %}
14 |
6 |
Site Rules
7 | {#
Spoilers: Your account will be banned if you repeatedly post these without using the [spoiler] tag properly. #}
8 |
Breaking any of the rules on this page may result in being banned
9 |
Shitposting and Trolling: Your account will be banned if you keep this up. Repeatedly making inaccurate/false reports falls under this rule as well.
10 |
Bumping: Your account will be banned if you keep deleting and reposting your torrents.
11 |
Flooding: If you have five or more releases of the same type to release in one go, make a batch torrent containing all of them.
12 |
URL redirection services: These are removed on sight along with their torrents.
13 |
Advertising: No.
14 |
Content restrictions: This site is for content that originates from and/or is specific to China, Japan, and/or Korea.
15 |
Other content is not allowed without exceptions and will be removed.
16 |
{{ config.EXTERNAL_URLS['main'] }} is for work-safe content only. The following rules apply:
17 |
18 |
19 | No pornography of any kind.
20 |
21 |
22 | No extreme visual content. This means no scat, gore, or any other of such things.
23 |
24 |
25 | Troll torrents are not allowed. These will be removed on sight.
26 |
27 |
28 |
{{ config.EXTERNAL_URLS['fap'] }} is the place for non-work-safe content only. Still, the following rules apply:
29 |
30 |
31 | No extreme real life visual content. This means no scat, gore, bestiality, or any other of such things.
32 |
33 |
34 | Absolutely no real life child pornography of any kind.
35 |
36 |
37 | Troll torrents are not allowed. These will be removed on sight.
38 |
39 |
40 |
Torrent information: Text files (.txt) or info files (.nfo) for torrent or release group information are preferred.
41 |
Torrents containing (.chm) or (.url) files may be removed.
42 |
Upper limits on video resolution based on source:
43 |
44 |
45 | DVD source video is limited to 1024x576p.
46 |
47 |
48 | Web source video is limited to 1920x1080p or source resolution, whichever is lower.
49 |
50 |
51 | TV source video is by default limited to 1920x1080p. SD channels, however, are limited to 480p.
52 |
53 |
54 | Blu-ray source video is limited to 1920x1080p.
55 |
56 |
57 | UHD source video is limited to 3840x2160p.
58 |
59 |
60 |
Naturally, untouched sources are not bound by these limits. Leaks are also not subject to any resolution limits.
61 |
Finally, a few notes concerning tagging and using other people's work:
62 |
63 |
64 | Do not add your own tag(s) when reuploading an original release.
65 |
66 |
67 | Unless you are reuploading an original release, you should either avoid using tags that are not your own or make it extremely clear to everyone that you are the one responsible for the upload.
68 |
69 |
70 | If these policies are not obeyed, then those torrents will be removed if reported by a group or person commonly seen as the owner of the tag(s). This especially applies to remake torrents.
71 |
72 |
73 | Although only hinted at above, we may remove troll torrents tagged with A-sucks, B-is-slow, or such if reported by A or B.
74 |
75 |
76 | Remakes which are utterly bit rate-starved are not allowed.
77 |
78 |
79 | Remakes which add watermarks or such are not allowed.
80 |
81 |
82 | Remakes which reencode video to XviD or worse are not allowed.
83 |
84 |
85 | Remakes of JPG/PNG-based releases are not allowed without exceptions since there is most often no point in making such.
86 |
87 |
88 |
89 | {% endblock %}
90 |
--------------------------------------------------------------------------------
/utils/api_info.py:
--------------------------------------------------------------------------------
1 | #!/usr/bin/env python3
2 | import argparse
3 | import os
4 | import re
5 |
6 | import requests
7 |
8 | NYAA_HOST = 'https://nyaa.si'
9 | SUKEBEI_HOST = 'https://sukebei.nyaa.si'
10 |
11 | API_BASE = '/api'
12 | API_INFO = API_BASE + '/info'
13 |
14 | ID_PATTERN = '^[0-9]+$'
15 | INFO_HASH_PATTERN = '^[0-9a-fA-F]{40}$'
16 |
17 | environment_epillog = ('You may also provide environment variables NYAA_API_HOST, NYAA_API_USERNAME'
18 | ' and NYAA_API_PASSWORD for connection info.')
19 |
20 | parser = argparse.ArgumentParser(
21 | description='Query torrent info on Nyaa.si', epilog=environment_epillog)
22 |
23 | conn_group = parser.add_argument_group('Connection options')
24 |
25 | conn_group.add_argument('-s', '--sukebei', default=False,
26 | action='store_true', help='Query torrent info on sukebei.Nyaa.si')
27 |
28 | conn_group.add_argument('-u', '--user', help='Username or email')
29 | conn_group.add_argument('-p', '--password', help='Password')
30 | conn_group.add_argument('--host', help='Select another api host (for debugging purposes)')
31 |
32 | resp_group = parser.add_argument_group('Response options')
33 |
34 | resp_group.add_argument('--raw', default=False, action='store_true',
35 | help='Print only raw response (JSON)')
36 | resp_group.add_argument('-m', '--magnet', default=False,
37 | action='store_true', help='Print magnet uri')
38 |
39 |
40 | req_group = parser.add_argument_group('Required arguments')
41 | req_group.add_argument('hash_or_id', metavar='HASH_OR_ID',
42 | help='Torrent ID or hash (hex, 40 characters) to query for')
43 |
44 |
45 | def easy_file_size(filesize):
46 | for prefix in ['B', 'KiB', 'MiB', 'GiB', 'TiB']:
47 | if filesize < 1024.0:
48 | return '{0:.1f} {1}'.format(filesize, prefix)
49 | filesize = filesize / 1024.0
50 | return '{0:.1f} {1}'.format(filesize, prefix)
51 |
52 |
53 | def _as_yes_no(value):
54 | return 'Yes' if value else 'No'
55 |
56 |
57 | INFO_TEMPLATE = ("Torrent #{id}: '{name}' ({formatted_filesize}) uploaded by {submitter}"
58 | "\n {creation_date} [{main_category} - {sub_category}] [{flag_info}]")
59 | FLAG_NAMES = ['Trusted', 'Complete', 'Remake']
60 |
61 |
62 | if __name__ == '__main__':
63 | args = parser.parse_args()
64 |
65 | # Use debug host from args or environment, if set
66 | debug_host = args.host or os.getenv('NYAA_API_HOST')
67 | api_host = (debug_host or (args.sukebei and SUKEBEI_HOST or NYAA_HOST)).rstrip('/')
68 |
69 | api_query = args.hash_or_id.lower().strip()
70 |
71 | # Verify query is either a valid id or valid hash
72 | id_match = re.match(ID_PATTERN, api_query)
73 | hex_hash_match = re.match(INFO_HASH_PATTERN, api_query)
74 |
75 | if not (id_match or hex_hash_match):
76 | raise Exception("Given argument '{}' doesn't "
77 | "seem like an ID or a hex hash.".format(api_query))
78 |
79 | if id_match:
80 | # Remove leading zeroes
81 | api_query = api_query.lstrip('0')
82 |
83 | api_info_url = api_host + API_INFO + '/' + api_query
84 |
85 | api_username = args.user or os.getenv('NYAA_API_USERNAME')
86 | api_password = args.password or os.getenv('NYAA_API_PASSWORD')
87 |
88 | if not (api_username and api_password):
89 | raise Exception('No authorization found from arguments or environment variables.')
90 |
91 | auth = (api_username, api_password)
92 |
93 | # Go!
94 | r = requests.get(api_info_url, auth=auth)
95 |
96 | if args.raw:
97 | print(r.text)
98 | else:
99 | try:
100 | response = r.json()
101 | except ValueError:
102 | print('Bad response:')
103 | print(r.text)
104 | exit(1)
105 |
106 | errors = response.get('errors')
107 |
108 | if errors:
109 | print('Info request failed:', errors)
110 | exit(1)
111 | else:
112 | formatted_filesize = easy_file_size(response.get('filesize', 0))
113 | flag_info = ', '.join(
114 | n + ': ' + _as_yes_no(response['is_' + n.lower()]) for n in FLAG_NAMES)
115 |
116 | info_str = INFO_TEMPLATE.format(formatted_filesize=formatted_filesize,
117 | flag_info=flag_info, **response)
118 |
119 | print(info_str)
120 | if args.magnet:
121 | print(response['magnet'])
122 |
--------------------------------------------------------------------------------
/es_mapping.yml:
--------------------------------------------------------------------------------
1 | ---
2 | # CREATE DTABASE/TABLE equivalent for elasticsearch, in yaml
3 | # fo inline comments.
4 | settings:
5 | analysis:
6 | analyzer:
7 | my_search_analyzer:
8 | type: custom
9 | tokenizer: standard
10 | char_filter:
11 | - my_char_filter
12 | filter:
13 | - lowercase
14 | my_index_analyzer:
15 | type: custom
16 | tokenizer: standard
17 | char_filter:
18 | - my_char_filter
19 | filter:
20 | - resolution
21 | - lowercase
22 | - word_delimit
23 | - my_ngram
24 | - trim_zero
25 | - unique
26 | # For exact matching - separate each character for substring matching + lowercase
27 | exact_analyzer:
28 | tokenizer: exact_tokenizer
29 | filter:
30 | - lowercase
31 | # For matching full words longer than the ngram limit (15 chars)
32 | my_fullword_index_analyzer:
33 | type: custom
34 | tokenizer: standard
35 | char_filter:
36 | - my_char_filter
37 | filter:
38 | - lowercase
39 | - word_delimit
40 | # Skip tokens shorter than N characters,
41 | # since they're already indexed in the main field
42 | - fullword_min
43 | - unique
44 |
45 | tokenizer:
46 | # Splits input into characters, for exact substring matching
47 | exact_tokenizer:
48 | type: pattern
49 | pattern: "(.)"
50 | group: 1
51 |
52 | filter:
53 | my_ngram:
54 | type: edge_ngram
55 | min_gram: 1
56 | max_gram: 15
57 | fullword_min:
58 | type: length
59 | # Remember to change this if you change the max_gram below!
60 | min: 16
61 | resolution:
62 | type: pattern_capture
63 | patterns: ["(\\d+)[xX](\\d+)"]
64 | trim_zero:
65 | type: pattern_capture
66 | patterns: ["0*([0-9]*)"]
67 | word_delimit:
68 | type: word_delimiter_graph
69 | preserve_original: true
70 | split_on_numerics: false
71 | # https://www.elastic.co/guide/en/elasticsearch/reference/current/analysis-word-delimiter-graph-tokenfilter.html#word-delimiter-graph-tokenfilter-configure-parms
72 | # since we're using "trim" filters downstream, otherwise
73 | # you get weird lucene errors about startOffset
74 | adjust_offsets: false
75 | char_filter:
76 | my_char_filter:
77 | type: mapping
78 | mappings: ["-=>_", "!=>_", "_=>\\u0020"]
79 | index:
80 | # we're running a single es node, so no sharding necessary,
81 | # plus replicas don't really help either.
82 | number_of_shards: 1
83 | number_of_replicas : 0
84 | query:
85 | default_field: display_name
86 | mappings:
87 | # disable elasticsearch's "helpful" autoschema
88 | dynamic: false
89 | properties:
90 | id:
91 | type: long
92 | display_name:
93 | # TODO could do a fancier tokenizer here to parse out the
94 | # the scene convention of stuff in brackets, plus stuff like k-on
95 | type: text
96 | analyzer: my_index_analyzer
97 | fielddata: true # Is this required?
98 | fields:
99 | # Multi-field for full-word matching (when going over ngram limits)
100 | # Note: will have to be queried for, not automatic
101 | fullword:
102 | type: text
103 | analyzer: my_fullword_index_analyzer
104 | # Stored for exact phrase matching
105 | exact:
106 | type: text
107 | analyzer: exact_analyzer
108 | created_time:
109 | type: date
110 | #
111 | # Only in the ES index for generating magnet links
112 | info_hash:
113 | type: keyword
114 | index: false
115 | filesize:
116 | type: long
117 | anonymous:
118 | type: boolean
119 | trusted:
120 | type: boolean
121 | remake:
122 | type: boolean
123 | complete:
124 | type: boolean
125 | hidden:
126 | type: boolean
127 | deleted:
128 | type: boolean
129 | has_torrent:
130 | type: boolean
131 | download_count:
132 | type: long
133 | leech_count:
134 | type: long
135 | seed_count:
136 | type: long
137 | comment_count:
138 | type: long
139 | # these ids are really only for filtering, thus keyword
140 | uploader_id:
141 | type: keyword
142 | main_category_id:
143 | type: keyword
144 | sub_category_id:
145 | type: keyword
146 |
--------------------------------------------------------------------------------
/nyaa/templates/_formhelpers.html:
--------------------------------------------------------------------------------
1 | {% macro render_field(field, render_label=True) %}
2 | {% if field.errors %}
3 |