Compare commits

...

12 Commits
v0.1 ... v0.2.1

Author SHA1 Message Date
Simon Ding
fb638dff8b fix: download dir 2024-07-23 19:50:50 +08:00
Simon Ding
11f7b51eb5 add movie download history 2024-07-23 19:01:24 +08:00
Simon Ding
d2439480c8 update redame 2024-07-23 15:49:48 +08:00
Simon Ding
6826422c2b feat: option to change file hash when update to webdav 2024-07-23 15:18:42 +08:00
Simon Ding
8d2ce9752b feat: all in adaptive scafford & change seed color 2024-07-23 14:02:27 +08:00
Simon Ding
7e5feaf998 feat: adaptive ui & change colors 2024-07-23 13:42:42 +08:00
Simon Ding
e0bdd88706 feat: change name 2024-07-23 10:23:09 +08:00
Simon Ding
74d5bf54b9 chore: update readme 2024-07-22 17:55:50 +08:00
Simon Ding
03a3bf6d90 fix: entrypoint 2024-07-22 17:21:30 +08:00
Simon Ding
ee23b75390 update readme 2024-07-22 16:32:09 +08:00
Simon Ding
6e9b88b09b chore: rename 2024-07-22 15:56:52 +08:00
Simon Ding
93525ae883 chore: rename 2024-07-22 15:50:55 +08:00
20 changed files with 495 additions and 233 deletions

View File

@@ -1,4 +1,4 @@
name: Create and publish a Docker image
name: build docker image
on:
workflow_dispatch:

View File

@@ -1,4 +1,4 @@
name: Create and publish a Docker image
name: release docker image
on:
workflow_dispatch:
@@ -12,7 +12,7 @@ env:
jobs:
build-and-push-image:
build-and-release-image:
runs-on: ubuntu-latest
permissions:
contents: read

View File

@@ -32,4 +32,6 @@ RUN apt-get update && apt-get -y install ca-certificates
# 将上一个阶段publish文件夹下的所有文件复制进来
COPY --from=builder /app/polaris .
EXPOSE 8080
EXPOSE 8080
ENTRYPOINT ["./polaris"]

View File

@@ -23,15 +23,30 @@ Polaris 是一个电视剧和电影的追踪软件。配置好了之后,当剧
最简单部署 Polaris 的方式是使用 docker compose
```yaml
services:
polaris:
image: ghcr.io/simon-ding/polaris:latest
restart: always
volumes:
- ./config/polaris:/app/data #程序配置文件路径
- /downloads:/downloads #下载路径,需要和下载客户端配置一致
- /data:/data #数据存储路径
- /data:/data #媒体数据存储路径也可以启动自己配置webdav存储
ports:
- 8080:8080
transmission: #下载客户端,也可以不安装使用已有的
image: lscr.io/linuxserver/transmission:latest
container_name: transmission
environment:
- PUID=1000
- PGID=1000
- TZ=Asia/Shanghai
volumes:
- ./config/transmission:/config
- /downloads:/downloads #此路径要与polaris下载路径保持一致
ports:
- 9091:9091
- 51413:51413
- 51413:51413/udp
```
拉起之后访问 http://< ip >:8080 的形式访问

View File

@@ -311,11 +311,12 @@ type LocalDirSetting struct {
}
type WebdavSetting struct {
URL string `json:"url"`
TvPath string `json:"tv_path"`
MoviePath string `json:"movie_path"`
User string `json:"user"`
Password string `json:"password"`
URL string `json:"url"`
TvPath string `json:"tv_path"`
MoviePath string `json:"movie_path"`
User string `json:"user"`
Password string `json:"password"`
ChangeFileHash string `json:"change_file_hash"`
}
func (c *Client) AddStorage(st *StorageInfo) error {
@@ -484,3 +485,8 @@ func (c *Client) SetSeasonAllEpisodeStatus(mediaID, seasonNum int, status episod
func (c *Client) TmdbIdInWatchlist(tmdb_id int) bool {
return c.ent.Media.Query().Where(media.TmdbID(tmdb_id)).CountX(context.TODO()) > 0
}
func (c *Client) GetDownloadHistory(mediaID int) ([]*ent.History, error) {
return c.ent.History.Query().Where(history.MediaID(mediaID)).All(context.TODO())
}

View File

@@ -7,6 +7,7 @@ import (
"path/filepath"
"polaris/log"
"polaris/pkg/gowebdav"
"polaris/pkg/utils"
"github.com/gabriel-vasile/mimetype"
"github.com/pkg/errors"
@@ -15,9 +16,10 @@ import (
type WebdavStorage struct {
fs *gowebdav.Client
dir string
changeMediaHash bool
}
func NewWebdavStorage(url, user, password, path string) (*WebdavStorage, error) {
func NewWebdavStorage(url, user, password, path string, changeMediaHash bool) (*WebdavStorage, error) {
c := gowebdav.NewClient(url, user, password)
if err := c.Connect(); err != nil {
return nil, errors.Wrap(err, "connect webdav")
@@ -53,6 +55,11 @@ func (w *WebdavStorage) Move(local, remote string) error {
// }
} else { //is file
if w.changeMediaHash {
if err := utils.ChangeFileHash(path); err != nil {
log.Errorf("change file %v hash error: %v", path, err)
}
}
if f, err := os.OpenFile(path, os.O_RDONLY, 0666); err != nil {
return errors.Wrapf(err, "read file %v", path)
} else { //open success

View File

@@ -1,6 +1,7 @@
package utils
import (
"os"
"regexp"
"strconv"
"strings"
@@ -146,3 +147,16 @@ func AvailableSpace(dir string) uint64 {
unix.Statfs(dir, &stat)
return stat.Bavail * uint64(stat.Bsize)
}
func ChangeFileHash(name string) error {
f, err := os.OpenFile(name, os.O_APPEND|os.O_WRONLY, 0655)
if err != nil {
return errors.Wrap(err, "open file")
}
defer f.Close()
_, err = f.Write([]byte("\000"))
if err != nil {
return errors.Wrap(err, "write file")
}
return nil
}

View File

@@ -1,6 +1,7 @@
package server
import (
"fmt"
"polaris/ent"
"polaris/ent/episode"
"polaris/log"
@@ -72,3 +73,15 @@ func (s *Server) RemoveActivity(c *gin.Context) (interface{}, error) {
log.Infof("history record successful deleted: %v", his.SourceTitle)
return nil, nil
}
func (s *Server) GetMediaDownloadHistory(c *gin.Context) (interface{}, error) {
var ids = c.Param("id")
id, err := strconv.Atoi(ids)
if err != nil {
return nil, fmt.Errorf("id is not correct: %v", ids)
}
his, err := s.db.GetDownloadHistory(id)
if err != nil {
return nil, errors.Wrap(err, "db")
}
return his, nil
}

View File

@@ -101,7 +101,7 @@ func (s *Server) moveCompletedTask(id int) (err1 error) {
if series.MediaType == media.MediaTypeMovie {
targetPath = ws.MoviePath
}
storageImpl, err := storage.NewWebdavStorage(ws.URL, ws.User, ws.Password, targetPath)
storageImpl, err := storage.NewWebdavStorage(ws.URL, ws.User, ws.Password, targetPath, ws.ChangeFileHash == "true")
if err != nil {
return errors.Wrap(err, "new webdav")
}
@@ -162,7 +162,7 @@ func (s *Server) checkDownloadedSeriesFiles(m *ent.Media) error {
case storage1.ImplementationWebdav:
ws := st.ToWebDavSetting()
targetPath := ws.TvPath
storageImpl1, err := storage.NewWebdavStorage(ws.URL, ws.User, ws.Password, targetPath)
storageImpl1, err := storage.NewWebdavStorage(ws.URL, ws.User, ws.Password, targetPath, ws.ChangeFileHash == "true")
if err != nil {
return errors.Wrap(err, "new webdav")
}

View File

@@ -62,6 +62,7 @@ func (s *Server) Serve() error {
{
activity.GET("/", HttpHandler(s.GetAllActivities))
activity.DELETE("/:id", HttpHandler(s.RemoveActivity))
activity.GET("/media/:id", HttpHandler(s.GetMediaDownloadHistory))
}
tv := api.Group("/media")

View File

@@ -2,6 +2,7 @@ package server
import (
"polaris/db"
"polaris/log"
"github.com/gin-gonic/gin"
"github.com/pkg/errors"
@@ -17,12 +18,13 @@ func (s *Server) SetSetting(c *gin.Context) (interface{}, error) {
if err := c.ShouldBindJSON(&in); err != nil {
return nil, errors.Wrap(err, "bind json")
}
log.Infof("set setting input: %+v", in)
if in.TmdbApiKey != "" {
if err := s.db.SetSetting(db.SettingTmdbApiKey, in.TmdbApiKey); err != nil {
return nil, errors.Wrap(err, "save tmdb api")
}
}
if in.DownloadDir == "" {
if in.DownloadDir != "" {
if err := s.db.SetSetting(db.SettingDownloadDir, in.DownloadDir); err != nil {
return nil, errors.Wrap(err, "save download dir")
}
@@ -33,7 +35,8 @@ func (s *Server) SetSetting(c *gin.Context) (interface{}, error) {
func (s *Server) GetSetting(c *gin.Context) (interface{}, error) {
tmdb := s.db.GetSetting(db.SettingTmdbApiKey)
downloadDir := s.db.GetSetting(db.SettingDownloadDir)
return &GeneralSettings{
return &GeneralSettings{
TmdbApiKey: tmdb,
DownloadDir: downloadDir,
}, nil

View File

@@ -1,11 +1,11 @@
import 'package:flutter/material.dart';
import 'package:flutter_adaptive_scaffold/flutter_adaptive_scaffold.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:go_router/go_router.dart';
import 'package:shared_preferences/shared_preferences.dart';
import 'package:ui/activity.dart';
import 'package:ui/login_page.dart';
import 'package:ui/movie_watchlist.dart';
import 'package:ui/navdrawer.dart';
import 'package:ui/providers/APIs.dart';
import 'package:ui/search.dart';
import 'package:ui/system_settings.dart';
@@ -16,9 +16,18 @@ void main() {
runApp(const MyApp());
}
class MyApp extends StatelessWidget {
class MyApp extends ConsumerStatefulWidget {
const MyApp({super.key});
@override
ConsumerState<ConsumerStatefulWidget> createState() {
return _MyAppState();
}
}
class _MyAppState extends ConsumerState<MyApp> {
// This widget is the root of your application.
@override
Widget build(BuildContext context) {
@@ -26,95 +35,8 @@ class MyApp extends StatelessWidget {
final shellRoute = ShellRoute(
builder: (BuildContext context, GoRouterState state, Widget child) {
return SelectionArea(
child: Scaffold(
appBar: AppBar(
// TRY THIS: Try changing the color here to a specific color (to
// Colors.amber, perhaps?) and trigger a hot reload to see the AppBar
// change color while the other colors stay the same.
backgroundColor: Theme.of(context).colorScheme.inversePrimary,
// Here we take the value from the MyHomePage object that was created by
// the App.build method, and use it to set our appbar title.
title: const Row(
children: [
Text("Polaris"),
],
),
actions: [
SearchAnchor(builder:
(BuildContext context, SearchController controller) {
return Container(
constraints:
const BoxConstraints(maxWidth: 300, maxHeight: 40),
child: Opacity(
opacity: 0.8,
child: SearchBar(
hintText: "搜索...",
leading: const Icon(Icons.search),
controller: controller,
shadowColor: WidgetStateColor.transparent,
backgroundColor: const WidgetStatePropertyAll(Color.fromARGB(255, 29, 78, 119)),
onSubmitted: (value) => context.go(Uri(
path: SearchPage.route,
queryParameters: {'query': value}).toString()),
),
),
);
}, suggestionsBuilder:
(BuildContext context, SearchController controller) {
return [Text("dadada")];
}),
FutureBuilder(
future: APIs.isLoggedIn(),
builder: (context, snapshot) {
if (snapshot.hasData && snapshot.data == true) {
return MenuAnchor(
menuChildren: [
MenuItemButton(
leadingIcon: const Icon(Icons.exit_to_app),
child: const Text("登出"),
onPressed: () async {
final SharedPreferences prefs =
await SharedPreferences.getInstance();
await prefs.remove('token');
if (context.mounted) {
context.go(LoginScreen.route);
}
},
),
],
builder: (context, controller, child) {
return TextButton(
onPressed: () {
if (controller.isOpen) {
controller.close();
} else {
controller.open();
}
},
child: const Icon(Icons.account_circle),
);
},
);
}
return Container();
})
],
),
body: Center(
// Center is a layout widget. It takes a single child and positions it
// in the middle of the parent.
child: Flex(direction: Axis.horizontal, children: <Widget>[
const Flexible(
flex: 1,
child: NavDrawer(),
),
const VerticalDivider(thickness: 1, width: 1),
Flexible(
flex: 7,
child:
Padding(padding: const EdgeInsets.all(20), child: child),
)
]))),
child: MainSkeleton(body: Padding(padding: const EdgeInsets.all(20), child: child),
),
);
},
routes: [
@@ -173,24 +95,159 @@ class MyApp extends StatelessWidget {
theme: ThemeData(
fontFamily: "NotoSansSC",
colorScheme: ColorScheme.fromSeed(
seedColor: Colors.blue, brightness: Brightness.dark),
seedColor: Colors.blueAccent, brightness: Brightness.dark, surface: Colors.black54),
useMaterial3: true,
//scaffoldBackgroundColor: Color.fromARGB(255, 26, 24, 24)
),
routerConfig: router,
),
);
}
}
CustomTransitionPage buildPageWithDefaultTransition<T>({
required BuildContext context,
required GoRouterState state,
required Widget child,
}) {
return CustomTransitionPage<T>(
key: state.pageKey,
child: child,
transitionsBuilder: (context, animation, secondaryAnimation, child) =>
FadeTransition(opacity: animation, child: child),
);
class MainSkeleton extends StatefulWidget {
final Widget body;
const MainSkeleton({super.key, required this.body});
@override
State<StatefulWidget> createState() {
return _MainSkeletonState();
}
}
class _MainSkeletonState extends State<MainSkeleton> {
var _selectedTab;
@override
Widget build(BuildContext context) {
var uri = GoRouterState.of(context).uri.toString();
if (uri.contains(WelcomePage.routeTv)) {
_selectedTab = 0;
} else if (uri.contains(WelcomePage.routeMoivie)) {
_selectedTab = 1;
} else if (uri.contains(ActivityPage.route)) {
_selectedTab = 2;
} else if (uri.contains(SystemSettingsPage.route)) {
_selectedTab = 3;
}
return AdaptiveScaffold(
appBarBreakpoint: Breakpoints.standard,
appBar: AppBar(
// TRY THIS: Try changing the color here to a specific color (to
// Colors.amber, perhaps?) and trigger a hot reload to see the AppBar
// change color while the other colors stay the same.
backgroundColor: Theme.of(context).colorScheme.inversePrimary,
// Here we take the value from the MyHomePage object that was created by
// the App.build method, and use it to set our appbar title.
title: const Row(
children: [
Text("Polaris"),
],
),
actions: [
SearchAnchor(builder:
(BuildContext context, SearchController controller) {
return Container(
constraints:
const BoxConstraints(maxWidth: 300, maxHeight: 40),
child: Opacity(
opacity: 0.8,
child: SearchBar(
hintText: "搜索...",
leading: const Icon(Icons.search),
controller: controller,
shadowColor: WidgetStateColor.transparent,
backgroundColor: WidgetStatePropertyAll(
Theme.of(context).colorScheme.primaryContainer
),
onSubmitted: (value) => context.go(Uri(
path: SearchPage.route,
queryParameters: {'query': value}).toString()),
),
),
);
}, suggestionsBuilder:
(BuildContext context, SearchController controller) {
return [Text("dadada")];
}),
FutureBuilder(
future: APIs.isLoggedIn(),
builder: (context, snapshot) {
if (snapshot.hasData && snapshot.data == true) {
return MenuAnchor(
menuChildren: [
MenuItemButton(
leadingIcon: const Icon(Icons.exit_to_app),
child: const Text("登出"),
onPressed: () async {
final SharedPreferences prefs =
await SharedPreferences.getInstance();
await prefs.remove('token');
if (context.mounted) {
context.go(LoginScreen.route);
}
},
),
],
builder: (context, controller, child) {
return TextButton(
onPressed: () {
if (controller.isOpen) {
controller.close();
} else {
controller.open();
}
},
child: const Icon(Icons.account_circle),
);
},
);
}
return Container();
})
],
),
useDrawer: false,
selectedIndex: _selectedTab,
onSelectedIndexChange: (int index) {
setState(() {
_selectedTab = index;
});
if (index == 0) {
context.go(WelcomePage.routeTv);
} else if (index == 1) {
context.go(WelcomePage.routeMoivie);
} else if (index == 2) {
context.go(ActivityPage.route);
} else if (index == 3) {
context.go(SystemSettingsPage.route);
}
},
destinations: const <NavigationDestination>[
NavigationDestination(
icon: Icon(Icons.live_tv),
label: '电视剧',
),
NavigationDestination(
icon: Icon(Icons.movie),
label: '电影',
),
NavigationDestination(
icon: Icon(Icons.download),
label: '活动',
),
NavigationDestination(
icon: Icon(Icons.settings),
label: '设置',
),
],
body: (context) => widget.body,
// Define a default secondaryBody.
// Override the default secondaryBody during the smallBreakpoint to be
// empty. Must use AdaptiveScaffold.emptyBuilder to ensure it is properly
// overridden.
);
}
}

View File

@@ -2,6 +2,7 @@ import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:go_router/go_router.dart';
import 'package:ui/providers/APIs.dart';
import 'package:ui/providers/activity.dart';
import 'package:ui/providers/series_details.dart';
import 'package:ui/providers/settings.dart';
import 'package:ui/providers/welcome_data.dart';
@@ -30,7 +31,6 @@ class _MovieDetailsPageState extends ConsumerState<MovieDetailsPage> {
@override
Widget build(BuildContext context) {
var seriesDetails = ref.watch(mediaDetailsProvider(widget.id));
var torrents = ref.watch(movieTorrentsDataProvider(widget.id));
var storage = ref.watch(storageSettingProvider);
return seriesDetails.when(
@@ -40,122 +40,100 @@ class _MovieDetailsPageState extends ConsumerState<MovieDetailsPage> {
Card(
margin: const EdgeInsets.all(4),
clipBehavior: Clip.hardEdge,
child: Padding(
padding: const EdgeInsets.all(10),
child: Row(
children: <Widget>[
Flexible(
flex: 1,
child: Padding(
padding: const EdgeInsets.all(10),
child: Image.network(
"${APIs.imagesUrl}/${details.id}/poster.jpg",
fit: BoxFit.contain,
headers: APIs.authHeaders,
child: Container(
decoration: BoxDecoration(
image: DecorationImage(
fit: BoxFit.fitWidth,
opacity: 0.5,
image: NetworkImage(
"${APIs.imagesUrl}/${details.id}/backdrop.jpg",
headers: APIs.authHeaders))),
child: Padding(
padding: const EdgeInsets.all(10),
child: Row(
children: <Widget>[
Flexible(
flex: 1,
child: Padding(
padding: const EdgeInsets.all(10),
child: Image.network(
"${APIs.imagesUrl}/${details.id}/poster.jpg",
fit: BoxFit.contain,
headers: APIs.authHeaders,
),
),
),
),
),
Expanded(
flex: 6,
child: Row(
children: [
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
Expanded(
flex: 6,
child: Row(
children: [
Row(
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text("${details.resolution}"),
const SizedBox(
width: 30,
Row(
children: [
Text("${details.resolution}"),
const SizedBox(
width: 30,
),
storage.when(
data: (value) {
for (final s in value) {
if (s.id == details.storageId) {
return Text(
"${s.name}(${s.implementation})");
}
}
return const Text("未知存储");
},
error: (error, stackTrace) =>
Text("$error"),
loading: () =>
const MyProgressIndicator()),
],
),
const Divider(thickness: 1, height: 1),
Text(
"${details.name} (${details.airDate!.split("-")[0]})",
style: const TextStyle(
fontSize: 20,
fontWeight: FontWeight.bold),
),
const Text(""),
Text(
details.overview!,
),
storage.when(
data: (value) {
for (final s in value) {
if (s.id == details.storageId) {
return Text(
"${s.name}(${s.implementation})");
}
}
return const Text("未知存储");
},
error: (error, stackTrace) =>
Text("$error"),
loading: () =>
const MyProgressIndicator()),
],
),
const Divider(thickness: 1, height: 1),
Text(
"${details.name} (${details.airDate!.split("-")[0]})",
style: const TextStyle(
fontSize: 20,
fontWeight: FontWeight.bold),
),
const Text(""),
Text(
details.overview!,
),
)),
Column(
children: [
IconButton(
onPressed: () {
ref
.read(mediaDetailsProvider(
widget.id)
.notifier)
.delete()
.whenComplete(() => context
.go(WelcomePage.routeMoivie))
.onError((error, trace) =>
Utils.showSnakeBar(
"删除失败:$error"));
},
icon: const Icon(Icons.delete))
],
)
],
)),
Column(
children: [
IconButton(
onPressed: () {
ref
.read(mediaDetailsProvider(widget.id)
.notifier)
.delete()
.whenComplete(() => context
.go(WelcomePage.routeMoivie))
.onError((error, trace) =>
Utils.showSnakeBar(
"删除失败:$error"));
},
icon: const Icon(Icons.delete))
],
)
],
),
),
),
],
),
],
),
),
)),
),
torrents.when(
data: (v) {
return DataTable(
columns: const [
DataColumn(label: Text("名称")),
DataColumn(label: Text("大小")),
DataColumn(label: Text("seeders")),
DataColumn(label: Text("peers")),
DataColumn(label: Text("操作"))
],
rows: List.generate(v.length, (i) {
final torrent = v[i];
return DataRow(cells: [
DataCell(Text("${torrent.name}")),
DataCell(Text("${torrent.size?.readableFileSize()}")),
DataCell(Text("${torrent.seeders}")),
DataCell(Text("${torrent.peers}")),
DataCell(IconButton(
icon: const Icon(Icons.download),
onPressed: () {
ref
.read(movieTorrentsDataProvider(widget.id)
.notifier)
.download(torrent.link!)
.whenComplete(() => Utils.showSnakeBar(
"开始下载:${torrent.name}")).onError((error, trace) => Utils.showSnakeBar("操作失败: $error"));
},
))
]);
}),
);
},
error: (error, trace) => Text("$error"),
loading: () => const MyProgressIndicator()),
NestedTabBar(
id: widget.id,
)
],
);
},
@@ -165,3 +143,125 @@ class _MovieDetailsPageState extends ConsumerState<MovieDetailsPage> {
loading: () => const MyProgressIndicator());
}
}
class NestedTabBar extends ConsumerStatefulWidget {
final String id;
const NestedTabBar({super.key, required this.id});
@override
_NestedTabBarState createState() => _NestedTabBarState();
}
class _NestedTabBarState extends ConsumerState<NestedTabBar>
with TickerProviderStateMixin {
late TabController _nestedTabController;
@override
void initState() {
super.initState();
_nestedTabController = new TabController(length: 2, vsync: this);
}
@override
void dispose() {
super.dispose();
_nestedTabController.dispose();
}
int selectedTab = 0;
@override
Widget build(BuildContext context) {
var torrents = ref.watch(movieTorrentsDataProvider(widget.id));
var histories = ref.watch(mediaHistoryDataProvider(widget.id));
return Column(
mainAxisAlignment: MainAxisAlignment.spaceAround,
crossAxisAlignment: CrossAxisAlignment.stretch,
children: <Widget>[
TabBar(
controller: _nestedTabController,
isScrollable: true,
onTap: (value) {
setState(() {
selectedTab = value;
});
},
tabs: const <Widget>[
Tab(
text: "下载记录",
),
Tab(
text: "资源",
),
],
),
Builder(builder: (context) {
if (selectedTab == 0) {
return histories.when(
data: (v) {
if (v.isEmpty) {
return const Center(
child: Text("无下载记录"),
);
}
return DataTable(
columns: const [
DataColumn(label: Text("#"), numeric: true),
DataColumn(label: Text("名称")),
DataColumn(label: Text("下载时间")),
],
rows: List.generate(v.length, (i) {
final activity = v[i];
return DataRow(cells: [
DataCell(Text("${activity.id}")),
DataCell(Text("${activity.sourceTitle}")),
DataCell(Text("${activity.date!.toLocal()}")),
]);
}));
},
error: (error, trace) => Text("$error"),
loading: () => const MyProgressIndicator());
} else {
return torrents.when(
data: (v) {
return DataTable(
columns: const [
DataColumn(label: Text("名称")),
DataColumn(label: Text("大小")),
DataColumn(label: Text("seeders")),
DataColumn(label: Text("peers")),
DataColumn(label: Text("操作"))
],
rows: List.generate(v.length, (i) {
final torrent = v[i];
return DataRow(cells: [
DataCell(Text("${torrent.name}")),
DataCell(Text("${torrent.size?.readableFileSize()}")),
DataCell(Text("${torrent.seeders}")),
DataCell(Text("${torrent.peers}")),
DataCell(IconButton(
icon: const Icon(Icons.download),
onPressed: () {
ref
.read(movieTorrentsDataProvider(widget.id)
.notifier)
.download(torrent.link!)
.whenComplete(() =>
Utils.showSnakeBar("开始下载:${torrent.name}"))
.onError((error, trace) =>
Utils.showSnakeBar("操作失败: $error"));
},
))
]);
}),
);
},
error: (error, trace) => Text("$error"),
loading: () => const MyProgressIndicator());
}
})
],
);
}
}

View File

@@ -27,6 +27,7 @@ class APIs {
static final loginUrl = "$_baseUrl/api/login";
static final loginSettingUrl = "$_baseUrl/api/v1/setting/auth";
static final activityUrl = "$_baseUrl/api/v1/activity/";
static final activityMediaUrl = "$_baseUrl/api/v1/activity/media/";
static final imagesUrl = "$_baseUrl/api/v1/img";
static final tmdbImgBaseUrl = "$_baseUrl/api/v1/posters";

View File

@@ -8,10 +8,27 @@ var activitiesDataProvider =
AsyncNotifierProvider.autoDispose<ActivityData, List<Activity>>(
ActivityData.new);
var mediaHistoryDataProvider = FutureProvider.autoDispose.family(
(ref, arg) async {
final dio = await APIs.getDio();
var resp = await dio.get("${APIs.activityMediaUrl}$arg");
final sp = ServerResponse.fromJson(resp.data);
if (sp.code != 0) {
throw sp.message;
}
List<Activity> activities = List.empty(growable: true);
for (final a in sp.data as List) {
activities.add(Activity.fromJson(a));
}
return activities;
},
);
class ActivityData extends AutoDisposeAsyncNotifier<List<Activity>> {
@override
FutureOr<List<Activity>> build() async {
Timer(const Duration(seconds: 5), ref.invalidateSelf);//Periodically Refresh
Timer(
const Duration(seconds: 5), ref.invalidateSelf); //Periodically Refresh
final dio = await APIs.getDio();
var resp = await dio.get(APIs.activityUrl);

View File

@@ -258,7 +258,12 @@ class StorageSettingData extends AutoDisposeAsyncNotifier<List<Storage>> {
class Storage {
Storage(
{this.id, this.name, this.implementation, this.settings, this.isDefault});
{this.id,
this.name,
this.implementation,
this.settings,
this.isDefault,
});
final int? id;
final String? name;

View File

@@ -386,12 +386,15 @@ class _SystemSettingsPageState extends ConsumerState<SystemSettingsPage> {
var urlController = TextEditingController();
var userController = TextEditingController();
var passController = TextEditingController();
bool enablingChangeFileHash = false;
if (s.settings != null) {
tvPathController.text = s.settings!["tv_path"] ?? "";
moviePathController.text = s.settings!["movie_path"] ?? "";
urlController.text = s.settings!["url"] ?? "";
userController.text = s.settings!["user"] ?? "";
passController.text = s.settings!["password"] ?? "";
enablingChangeFileHash =
s.settings!["change_file_hash"] == "true" ? true : false;
}
String selectImpl = s.implementation == null ? "local" : s.implementation!;
@@ -419,7 +422,7 @@ class _SystemSettingsPageState extends ConsumerState<SystemSettingsPage> {
),
selectImpl != "local"
? Column(
crossAxisAlignment: CrossAxisAlignment.start,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
TextField(
decoration: const InputDecoration(labelText: "Webdav地址"),
@@ -433,6 +436,14 @@ class _SystemSettingsPageState extends ConsumerState<SystemSettingsPage> {
decoration: const InputDecoration(labelText: "密码"),
controller: passController,
),
CheckboxListTile(
title: const Text("上传时更改文件哈希", style: TextStyle(fontSize: 14),),
value: enablingChangeFileHash,
onChanged: (v) {
setState(() {
enablingChangeFileHash = v??false;
});
}),
],
)
: Container(),
@@ -456,7 +467,8 @@ class _SystemSettingsPageState extends ConsumerState<SystemSettingsPage> {
"movie_path": moviePathController.text,
"url": urlController.text,
"user": userController.text,
"password": passController.text
"password": passController.text,
"change_file_hash": enablingChangeFileHash ? "true" : "false"
},
));
}

View File

@@ -110,6 +110,14 @@ packages:
description: flutter
source: sdk
version: "0.0.0"
flutter_adaptive_scaffold:
dependency: "direct main"
description:
name: flutter_adaptive_scaffold
sha256: "56d4d81fe88ecffe8ae96b8d89a1ae793c0a85035bb9b74ff28f20eea0cdbdc2"
url: "https://pub.flutter-io.cn"
source: hosted
version: "0.1.11+1"
flutter_lints:
dependency: "direct dev"
description:

View File

@@ -43,6 +43,7 @@ dependencies:
shared_preferences: ^2.2.3
percent_indicator: ^4.2.3
intl: ^0.19.0
flutter_adaptive_scaffold: ^0.1.11+1
dev_dependencies:
flutter_test:

View File

@@ -1,6 +1,6 @@
{
"name": "ui",
"short_name": "ui",
"name": "Polaris",
"short_name": "Polaris",
"start_url": ".",
"display": "standalone",
"background_color": "#0175C2",