TikLocal 0.5.0__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- tiklocal-0.5.0/LICENSE +21 -0
- tiklocal-0.5.0/PKG-INFO +103 -0
- tiklocal-0.5.0/README.md +78 -0
- tiklocal-0.5.0/pyproject.toml +28 -0
- tiklocal-0.5.0/tiklocal/__init__.py +0 -0
- tiklocal-0.5.0/tiklocal/app.py +267 -0
- tiklocal-0.5.0/tiklocal/config.py +0 -0
- tiklocal-0.5.0/tiklocal/paths.py +26 -0
- tiklocal-0.5.0/tiklocal/run.py +139 -0
- tiklocal-0.5.0/tiklocal/services/__init__.py +181 -0
- tiklocal-0.5.0/tiklocal/services/thumbnail.py +57 -0
- tiklocal-0.5.0/tiklocal/static/feather.min.js +13 -0
- tiklocal-0.5.0/tiklocal/static/hammer.min.js +7 -0
- tiklocal-0.5.0/tiklocal/static/input.css +98 -0
- tiklocal-0.5.0/tiklocal/static/output.css +2 -0
- tiklocal-0.5.0/tiklocal/templates/base.html +176 -0
- tiklocal-0.5.0/tiklocal/templates/browse.html +199 -0
- tiklocal-0.5.0/tiklocal/templates/delete_confirm.html +12 -0
- tiklocal-0.5.0/tiklocal/templates/detail.html +617 -0
- tiklocal-0.5.0/tiklocal/templates/favorite.html +18 -0
- tiklocal-0.5.0/tiklocal/templates/gallery.html +183 -0
- tiklocal-0.5.0/tiklocal/templates/image_detail.html +398 -0
- tiklocal-0.5.0/tiklocal/templates/index.html +31 -0
- tiklocal-0.5.0/tiklocal/templates/settings.html +282 -0
- tiklocal-0.5.0/tiklocal/templates/tiktok.html +236 -0
- tiklocal-0.5.0/tiklocal/thumbs.py +277 -0
tiklocal-0.5.0/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2023 ChanMo
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
tiklocal-0.5.0/PKG-INFO
ADDED
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: TikLocal
|
|
3
|
+
Version: 0.5.0
|
|
4
|
+
Summary: A local media server that combines the features of TikTok and Pinterest
|
|
5
|
+
License: MIT
|
|
6
|
+
License-File: LICENSE
|
|
7
|
+
Keywords: tiklocal,tiktok,douyin,jellyfin,vlc
|
|
8
|
+
Author: ChanMo
|
|
9
|
+
Author-email: chan.mo@outlook.com
|
|
10
|
+
Requires-Python: >=3.10,<4.0
|
|
11
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
12
|
+
Classifier: Programming Language :: Python :: 3
|
|
13
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
14
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
15
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
16
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
17
|
+
Classifier: Programming Language :: Python :: 3.14
|
|
18
|
+
Requires-Dist: flask (>=3.1.0,<4.0.0)
|
|
19
|
+
Requires-Dist: pyyaml (>=6.0,<7.0)
|
|
20
|
+
Requires-Dist: waitress (>=3.0.2,<4.0.0)
|
|
21
|
+
Project-URL: Homepage, https://github.com/ChanMo/TikLocal
|
|
22
|
+
Project-URL: Repository, https://github.com/ChanMo/TikLocal
|
|
23
|
+
Description-Content-Type: text/markdown
|
|
24
|
+
|
|
25
|
+
# TikLocal
|
|
26
|
+
|
|
27
|
+
**TikLocal** is a **mobile and tablet** **web application** built on **Flask**. It allows you to browse and manage your local videos and images in a way similar to TikTok and Pinterest.
|
|
28
|
+
|
|
29
|
+
[中文](./README_zh.md)
|
|
30
|
+
|
|
31
|
+
## Introduction
|
|
32
|
+
|
|
33
|
+
TikLocal's main features include:
|
|
34
|
+
|
|
35
|
+
* **A TikTok-like swipe-up browsing experience** that allows you to easily and quickly browse local video files.
|
|
36
|
+
* **A file manager-like directory browsing** feature that allows you to easily find and manage local video files.
|
|
37
|
+
* **A Pinterest-like grid layout** feature that allows you to enjoy local images.
|
|
38
|
+
* **Support for light and dark modes** to suit your personal preferences.
|
|
39
|
+
|
|
40
|
+
## Use cases
|
|
41
|
+
|
|
42
|
+
TikLocal is suitable for the following use cases:
|
|
43
|
+
|
|
44
|
+
* You don't trust TikTok's teen mode and want to provide your child with completely controllable video content.
|
|
45
|
+
* You want to browse and manage your local videos and images locally, but don't want to use third-party cloud services.
|
|
46
|
+
* You want to use a TikTok-style video browsing experience on your phone or tablet.
|
|
47
|
+
* You want to use a Pinterest-style image browsing experience on your phone or tablet.
|
|
48
|
+
|
|
49
|
+
## How to use
|
|
50
|
+
|
|
51
|
+
### Installation
|
|
52
|
+
|
|
53
|
+
TikLocal is a Python application that you can install using the following command:
|
|
54
|
+
|
|
55
|
+
```
|
|
56
|
+
pip install tiklocal
|
|
57
|
+
```
|
|
58
|
+
|
|
59
|
+
### Usage
|
|
60
|
+
|
|
61
|
+
Starting TikLocal is very simple, just run the following command:
|
|
62
|
+
|
|
63
|
+
```
|
|
64
|
+
tiklocal ~/Videos/
|
|
65
|
+
```
|
|
66
|
+
|
|
67
|
+
You can specify any media folder.
|
|
68
|
+
|
|
69
|
+
To close, press `Ctrl + C`.
|
|
70
|
+
|
|
71
|
+
### Configuration
|
|
72
|
+
|
|
73
|
+
TikLocal provides some configuration options that you can adjust to your needs.
|
|
74
|
+
|
|
75
|
+
* **Light and dark modes:** You can choose to use light or dark mode.
|
|
76
|
+
* **Video playback speed:** You can adjust the video playback speed.
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
## TODO
|
|
80
|
+
|
|
81
|
+
* [ ] Add search
|
|
82
|
+
* [ ] Add more management operations, such as moving files and creating folders
|
|
83
|
+
* [ ] Add basic login control
|
|
84
|
+
* [ ] Add a bookmarking feature
|
|
85
|
+
* [ ] Add a Docker image
|
|
86
|
+
* [ ] Add a tagging feature
|
|
87
|
+
* [ ] Use recommendation algorithms
|
|
88
|
+
|
|
89
|
+
## Contribution
|
|
90
|
+
|
|
91
|
+
TikLocal is an open source project that you can contribute to in the following ways:
|
|
92
|
+
|
|
93
|
+
* Submit code or documentation improvements.
|
|
94
|
+
* Report bugs.
|
|
95
|
+
* Suggest new features.
|
|
96
|
+
|
|
97
|
+
## Contact us
|
|
98
|
+
|
|
99
|
+
If you have any questions or suggestions, you can contact us in the following ways:
|
|
100
|
+
|
|
101
|
+
* GitHub project page: [https://github.com/ChanMo/TikLocal/](https://github.com/ChanMo/TikLocal/)
|
|
102
|
+
* Email: [chan.mo@outlook.com]
|
|
103
|
+
|
tiklocal-0.5.0/README.md
ADDED
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
# TikLocal
|
|
2
|
+
|
|
3
|
+
**TikLocal** is a **mobile and tablet** **web application** built on **Flask**. It allows you to browse and manage your local videos and images in a way similar to TikTok and Pinterest.
|
|
4
|
+
|
|
5
|
+
[中文](./README_zh.md)
|
|
6
|
+
|
|
7
|
+
## Introduction
|
|
8
|
+
|
|
9
|
+
TikLocal's main features include:
|
|
10
|
+
|
|
11
|
+
* **A TikTok-like swipe-up browsing experience** that allows you to easily and quickly browse local video files.
|
|
12
|
+
* **A file manager-like directory browsing** feature that allows you to easily find and manage local video files.
|
|
13
|
+
* **A Pinterest-like grid layout** feature that allows you to enjoy local images.
|
|
14
|
+
* **Support for light and dark modes** to suit your personal preferences.
|
|
15
|
+
|
|
16
|
+
## Use cases
|
|
17
|
+
|
|
18
|
+
TikLocal is suitable for the following use cases:
|
|
19
|
+
|
|
20
|
+
* You don't trust TikTok's teen mode and want to provide your child with completely controllable video content.
|
|
21
|
+
* You want to browse and manage your local videos and images locally, but don't want to use third-party cloud services.
|
|
22
|
+
* You want to use a TikTok-style video browsing experience on your phone or tablet.
|
|
23
|
+
* You want to use a Pinterest-style image browsing experience on your phone or tablet.
|
|
24
|
+
|
|
25
|
+
## How to use
|
|
26
|
+
|
|
27
|
+
### Installation
|
|
28
|
+
|
|
29
|
+
TikLocal is a Python application that you can install using the following command:
|
|
30
|
+
|
|
31
|
+
```
|
|
32
|
+
pip install tiklocal
|
|
33
|
+
```
|
|
34
|
+
|
|
35
|
+
### Usage
|
|
36
|
+
|
|
37
|
+
Starting TikLocal is very simple, just run the following command:
|
|
38
|
+
|
|
39
|
+
```
|
|
40
|
+
tiklocal ~/Videos/
|
|
41
|
+
```
|
|
42
|
+
|
|
43
|
+
You can specify any media folder.
|
|
44
|
+
|
|
45
|
+
To close, press `Ctrl + C`.
|
|
46
|
+
|
|
47
|
+
### Configuration
|
|
48
|
+
|
|
49
|
+
TikLocal provides some configuration options that you can adjust to your needs.
|
|
50
|
+
|
|
51
|
+
* **Light and dark modes:** You can choose to use light or dark mode.
|
|
52
|
+
* **Video playback speed:** You can adjust the video playback speed.
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
## TODO
|
|
56
|
+
|
|
57
|
+
* [ ] Add search
|
|
58
|
+
* [ ] Add more management operations, such as moving files and creating folders
|
|
59
|
+
* [ ] Add basic login control
|
|
60
|
+
* [ ] Add a bookmarking feature
|
|
61
|
+
* [ ] Add a Docker image
|
|
62
|
+
* [ ] Add a tagging feature
|
|
63
|
+
* [ ] Use recommendation algorithms
|
|
64
|
+
|
|
65
|
+
## Contribution
|
|
66
|
+
|
|
67
|
+
TikLocal is an open source project that you can contribute to in the following ways:
|
|
68
|
+
|
|
69
|
+
* Submit code or documentation improvements.
|
|
70
|
+
* Report bugs.
|
|
71
|
+
* Suggest new features.
|
|
72
|
+
|
|
73
|
+
## Contact us
|
|
74
|
+
|
|
75
|
+
If you have any questions or suggestions, you can contact us in the following ways:
|
|
76
|
+
|
|
77
|
+
* GitHub project page: [https://github.com/ChanMo/TikLocal/](https://github.com/ChanMo/TikLocal/)
|
|
78
|
+
* Email: [chan.mo@outlook.com]
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
[tool.poetry]
|
|
2
|
+
name = "TikLocal"
|
|
3
|
+
version = "0.5.0"
|
|
4
|
+
description = "A local media server that combines the features of TikTok and Pinterest"
|
|
5
|
+
authors = ["ChanMo <chan.mo@outlook.com>"]
|
|
6
|
+
readme = "README.md"
|
|
7
|
+
license = "MIT"
|
|
8
|
+
homepage = "https://github.com/ChanMo/TikLocal"
|
|
9
|
+
repository = "https://github.com/ChanMo/TikLocal"
|
|
10
|
+
keywords = ["tiklocal", "tiktok", "douyin", "jellyfin", "vlc"]
|
|
11
|
+
|
|
12
|
+
[tool.poetry.scripts]
|
|
13
|
+
tiklocal = 'tiklocal.run:main'
|
|
14
|
+
|
|
15
|
+
[tool.poetry.dependencies]
|
|
16
|
+
python = ">=3.10,<4.0"
|
|
17
|
+
flask = "^3.1.0"
|
|
18
|
+
waitress = "^3.0.2"
|
|
19
|
+
pyyaml = "^6.0"
|
|
20
|
+
|
|
21
|
+
[[tool.poetry.source]]
|
|
22
|
+
name = "aliyun"
|
|
23
|
+
url = "https://mirrors.aliyun.com/pypi/simple"
|
|
24
|
+
priority = "primary"
|
|
25
|
+
|
|
26
|
+
[build-system]
|
|
27
|
+
requires = ["poetry-core"]
|
|
28
|
+
build-backend = "poetry.core.masonry.api"
|
|
File without changes
|
|
@@ -0,0 +1,267 @@
|
|
|
1
|
+
import os
|
|
2
|
+
import json
|
|
3
|
+
import random
|
|
4
|
+
import datetime
|
|
5
|
+
from urllib.parse import quote, unquote
|
|
6
|
+
from importlib.metadata import version, PackageNotFoundError
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
|
|
9
|
+
from flask import Flask, render_template, send_from_directory, request, redirect, send_file
|
|
10
|
+
|
|
11
|
+
# Service Imports
|
|
12
|
+
from tiklocal.services import LibraryService, FavoriteService, RecommendService
|
|
13
|
+
from tiklocal.services.thumbnail import ThumbnailService
|
|
14
|
+
|
|
15
|
+
try:
|
|
16
|
+
app_version = version("tiklocal")
|
|
17
|
+
except PackageNotFoundError:
|
|
18
|
+
app_version = '1.0.0'
|
|
19
|
+
|
|
20
|
+
def create_app(test_config=None):
|
|
21
|
+
app = Flask(__name__, instance_relative_config=True)
|
|
22
|
+
app.config.from_prefixed_env()
|
|
23
|
+
app.config.from_mapping(
|
|
24
|
+
SECRET_KEY = 'dev',
|
|
25
|
+
MEDIA_ROOT = Path(os.environ.get('MEDIA_ROOT', '.'))
|
|
26
|
+
)
|
|
27
|
+
app.config.from_pyfile('config.py', silent=True)
|
|
28
|
+
try:
|
|
29
|
+
os.makedirs(app.instance_path)
|
|
30
|
+
except OSError:
|
|
31
|
+
pass
|
|
32
|
+
|
|
33
|
+
# Initialize Services
|
|
34
|
+
media_root_str = str(app.config['MEDIA_ROOT'])
|
|
35
|
+
library_service = LibraryService(media_root_str)
|
|
36
|
+
favorite_service = FavoriteService(media_root_str)
|
|
37
|
+
recommend_service = RecommendService(library_service, favorite_service)
|
|
38
|
+
thumbnail_service = ThumbnailService(Path(media_root_str))
|
|
39
|
+
|
|
40
|
+
# --- Template Filters ---
|
|
41
|
+
@app.template_filter('timestamp_to_date')
|
|
42
|
+
def timestamp_to_date(timestamp):
|
|
43
|
+
try:
|
|
44
|
+
return datetime.datetime.fromtimestamp(timestamp).strftime('%Y-%m-%d %H:%M:%S')
|
|
45
|
+
except (ValueError, OSError):
|
|
46
|
+
return '未知时间'
|
|
47
|
+
|
|
48
|
+
@app.template_filter('filesizeformat')
|
|
49
|
+
def filesizeformat(num_bytes):
|
|
50
|
+
if num_bytes is None: return '0 B'
|
|
51
|
+
for unit in ['B', 'KB', 'MB', 'GB', 'TB']:
|
|
52
|
+
if num_bytes < 1024.0:
|
|
53
|
+
return f"{int(num_bytes) if unit == 'B' else f'{num_bytes:.1f}'} {unit}"
|
|
54
|
+
num_bytes /= 1024.0
|
|
55
|
+
return f"{num_bytes:.1f} PB"
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
# --- Web Routes ---
|
|
59
|
+
@app.route('/')
|
|
60
|
+
def tiktok():
|
|
61
|
+
"""Immersive Video Feed"""
|
|
62
|
+
return render_template('tiktok.html', menu='index')
|
|
63
|
+
|
|
64
|
+
@app.route('/gallery')
|
|
65
|
+
def gallery():
|
|
66
|
+
"""Immersive Image Discovery"""
|
|
67
|
+
return render_template('gallery.html', menu='gallery')
|
|
68
|
+
|
|
69
|
+
@app.route('/browse')
|
|
70
|
+
def browse():
|
|
71
|
+
"""Video Library List"""
|
|
72
|
+
# Using scan_videos instead of recursive get_files
|
|
73
|
+
videos = library_service.scan_videos()
|
|
74
|
+
|
|
75
|
+
# Filter Logic
|
|
76
|
+
size_mode = request.args.get('size', 'all')
|
|
77
|
+
min_mb = int(request.args.get('min_mb', 50))
|
|
78
|
+
|
|
79
|
+
if size_mode == 'big':
|
|
80
|
+
threshold = min_mb * 1024 * 1024
|
|
81
|
+
videos = [v for v in videos if v.stat().st_size >= threshold]
|
|
82
|
+
|
|
83
|
+
# Pagination
|
|
84
|
+
count = len(videos)
|
|
85
|
+
page = int(request.args.get('page', 1))
|
|
86
|
+
length = 20
|
|
87
|
+
offset = length * (page - 1)
|
|
88
|
+
|
|
89
|
+
# Convert to relative strings for template
|
|
90
|
+
sliced_videos = [library_service.get_relative_path(v) for v in videos[offset:offset+length]]
|
|
91
|
+
|
|
92
|
+
return render_template(
|
|
93
|
+
'browse.html',
|
|
94
|
+
page=page,
|
|
95
|
+
count=count,
|
|
96
|
+
length=length,
|
|
97
|
+
files=sliced_videos,
|
|
98
|
+
menu='browse',
|
|
99
|
+
size_mode=size_mode,
|
|
100
|
+
min_mb=min_mb,
|
|
101
|
+
has_min_mb=request.args.get('min_mb') is not None,
|
|
102
|
+
has_previous=page > 1,
|
|
103
|
+
has_next=len(videos) > offset + length
|
|
104
|
+
)
|
|
105
|
+
|
|
106
|
+
@app.route('/settings/')
|
|
107
|
+
def settings_view():
|
|
108
|
+
video_count = len(library_service.scan_videos())
|
|
109
|
+
return render_template('settings.html', menu='settings', version=app_version, videos=video_count)
|
|
110
|
+
|
|
111
|
+
@app.route('/library')
|
|
112
|
+
def library_redirect():
|
|
113
|
+
return redirect('/browse')
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
# --- Detail & Action Routes ---
|
|
117
|
+
|
|
118
|
+
@app.route('/detail/<path:name>')
|
|
119
|
+
def detail_view(name):
|
|
120
|
+
target = library_service.resolve_path(name)
|
|
121
|
+
if not target or not target.exists():
|
|
122
|
+
return "File not found", 404
|
|
123
|
+
|
|
124
|
+
# Context navigation (prev/next)
|
|
125
|
+
# Note: Re-scanning every request is inefficient for large libraries,
|
|
126
|
+
# but keeps state stateless. Optimization: Cache this.
|
|
127
|
+
videos = library_service.scan_videos()
|
|
128
|
+
video_names = [library_service.get_relative_path(v) for v in videos]
|
|
129
|
+
|
|
130
|
+
try:
|
|
131
|
+
index = video_names.index(name)
|
|
132
|
+
prev_item = video_names[index-1] if index > 0 else None
|
|
133
|
+
next_item = video_names[index+1] if index < len(video_names)-1 else None
|
|
134
|
+
except ValueError:
|
|
135
|
+
prev_item = next_item = None
|
|
136
|
+
|
|
137
|
+
return render_template(
|
|
138
|
+
'detail.html',
|
|
139
|
+
file=name,
|
|
140
|
+
mtime=datetime.datetime.fromtimestamp(target.stat().st_mtime).strftime('%Y-%m-%d %H:%M'),
|
|
141
|
+
size=target.stat().st_size,
|
|
142
|
+
previous_item=prev_item,
|
|
143
|
+
next_item=next_item
|
|
144
|
+
)
|
|
145
|
+
|
|
146
|
+
@app.route('/image')
|
|
147
|
+
def image_view():
|
|
148
|
+
uri = request.args.get('uri')
|
|
149
|
+
if not uri: return "Missing uri", 400
|
|
150
|
+
|
|
151
|
+
target = library_service.resolve_path(uri)
|
|
152
|
+
if not target or not target.exists(): return "File not found", 404
|
|
153
|
+
|
|
154
|
+
return render_template('image_detail.html', image=target, uri=uri, stat=target.stat())
|
|
155
|
+
|
|
156
|
+
@app.route("/delete/<path:name>", methods=['POST', 'GET'])
|
|
157
|
+
def delete_view(name):
|
|
158
|
+
target = library_service.resolve_path(name)
|
|
159
|
+
if request.method == 'POST':
|
|
160
|
+
if target and target.exists():
|
|
161
|
+
try:
|
|
162
|
+
target.unlink()
|
|
163
|
+
# Thumbnails are handled by OS or periodic cleanup, but ideally Service should handle it
|
|
164
|
+
except Exception as e:
|
|
165
|
+
return f"Error deleting file: {e}", 500
|
|
166
|
+
return redirect('/browse')
|
|
167
|
+
|
|
168
|
+
return render_template('delete_confirm.html', file=name)
|
|
169
|
+
|
|
170
|
+
@app.route("/delete", methods=['POST', 'GET'])
|
|
171
|
+
def delete_confirm_legacy():
|
|
172
|
+
# Legacy support for query param style
|
|
173
|
+
uri = request.args.get('uri')
|
|
174
|
+
if not uri: return redirect('/browse')
|
|
175
|
+
return redirect(f"/delete/{quote(uri)}")
|
|
176
|
+
|
|
177
|
+
@app.route('/favorite')
|
|
178
|
+
def favorite_view():
|
|
179
|
+
return render_template('favorite.html', files=list(favorite_service.load()))
|
|
180
|
+
|
|
181
|
+
|
|
182
|
+
# --- Media Serving Routes ---
|
|
183
|
+
|
|
184
|
+
@app.route("/media/<path:filename>")
|
|
185
|
+
def serve_media(filename):
|
|
186
|
+
# Consolidated media serving
|
|
187
|
+
try:
|
|
188
|
+
return send_from_directory(app.config["MEDIA_ROOT"], filename)
|
|
189
|
+
except Exception:
|
|
190
|
+
return "File not found", 404
|
|
191
|
+
|
|
192
|
+
@app.route("/media")
|
|
193
|
+
def serve_media_legacy():
|
|
194
|
+
# Legacy support for /media?uri=...
|
|
195
|
+
uri = request.args.get('uri')
|
|
196
|
+
if not uri: return "Missing uri", 400
|
|
197
|
+
return redirect(f"/media/{uri}")
|
|
198
|
+
|
|
199
|
+
@app.route('/thumb')
|
|
200
|
+
def thumb_view():
|
|
201
|
+
uri = request.args.get('uri')
|
|
202
|
+
if not uri: return send_file(io.BytesIO(thumbnail_service.placeholder), mimetype='image/png')
|
|
203
|
+
|
|
204
|
+
path, mimetype = thumbnail_service.get_thumbnail(unquote(uri))
|
|
205
|
+
if isinstance(path, bytes):
|
|
206
|
+
return send_file(io.BytesIO(path), mimetype=mimetype)
|
|
207
|
+
return send_file(path, mimetype=mimetype)
|
|
208
|
+
|
|
209
|
+
|
|
210
|
+
# --- API Routes ---
|
|
211
|
+
|
|
212
|
+
@app.route('/api/videos')
|
|
213
|
+
def api_videos():
|
|
214
|
+
# Clean JSON API
|
|
215
|
+
selected = recommend_service.get_weighted_selection(file_type='video', limit=20)
|
|
216
|
+
return json.dumps(selected)
|
|
217
|
+
|
|
218
|
+
@app.route('/api/random-images')
|
|
219
|
+
def api_random_images():
|
|
220
|
+
page = int(request.args.get('page', 1))
|
|
221
|
+
size = int(request.args.get('size', 30))
|
|
222
|
+
seed = request.args.get('seed') or str(random.randint(1, 999999))
|
|
223
|
+
|
|
224
|
+
# Get recommended images (all of them, weighted)
|
|
225
|
+
# Note: RecommendService currently returns a list. For true scale, we'd paginate inside Service.
|
|
226
|
+
# For now, consistent with previous behavior, we get all and slice.
|
|
227
|
+
all_images = recommend_service.get_weighted_selection(file_type='image', limit=99999, seed=seed)
|
|
228
|
+
|
|
229
|
+
total = len(all_images)
|
|
230
|
+
start = (page - 1) * size
|
|
231
|
+
end = start + size
|
|
232
|
+
page_images = all_images[start:end]
|
|
233
|
+
|
|
234
|
+
return {
|
|
235
|
+
'images': page_images,
|
|
236
|
+
'page': page,
|
|
237
|
+
'total': total,
|
|
238
|
+
'has_more': end < total,
|
|
239
|
+
'seed': seed
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
@app.route('/api/favorite/<path:name>', methods=['GET', 'POST'])
|
|
243
|
+
def api_favorite(name):
|
|
244
|
+
if request.method == 'GET':
|
|
245
|
+
return {'favorite': favorite_service.is_favorite(name)}
|
|
246
|
+
|
|
247
|
+
new_state = favorite_service.toggle(name)
|
|
248
|
+
return {'success': True, 'favorite': new_state}
|
|
249
|
+
|
|
250
|
+
@app.route('/api/thumbnail/<path:name>', methods=['POST'])
|
|
251
|
+
def api_set_thumbnail(name):
|
|
252
|
+
target = library_service.resolve_path(name)
|
|
253
|
+
if not target: return {'success': False, 'error': 'Invalid path'}, 400
|
|
254
|
+
|
|
255
|
+
payload = request.get_json(silent=True) or {}
|
|
256
|
+
ts = payload.get('time')
|
|
257
|
+
|
|
258
|
+
# This logic is a bit specific to app.py still, ideally move to ThumbnailService
|
|
259
|
+
# But for now, we just need to regen the thumb
|
|
260
|
+
thumb_path = thumbnail_service._get_thumb_path(name)
|
|
261
|
+
success = thumbnail_service._generate(target, thumb_path, timestamp=float(ts) if ts else None)
|
|
262
|
+
|
|
263
|
+
if success:
|
|
264
|
+
return {'success': True, 'url': f"/thumb?uri={quote(name)}&v={int(datetime.datetime.now().timestamp())}"}
|
|
265
|
+
return {'success': False, 'error': 'Failed to generate'}, 500
|
|
266
|
+
|
|
267
|
+
return app
|
|
File without changes
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
import os
|
|
2
|
+
from pathlib import Path
|
|
3
|
+
|
|
4
|
+
|
|
5
|
+
def get_data_dir() -> Path:
|
|
6
|
+
"""Return the global data directory for TikLocal.
|
|
7
|
+
Default to ~/.tiklocal unless TIKLOCAL_INSTANCE is set.
|
|
8
|
+
"""
|
|
9
|
+
base = os.environ.get('TIKLOCAL_INSTANCE')
|
|
10
|
+
if base:
|
|
11
|
+
p = Path(base).expanduser()
|
|
12
|
+
else:
|
|
13
|
+
p = Path.home() / '.tiklocal'
|
|
14
|
+
p.mkdir(parents=True, exist_ok=True)
|
|
15
|
+
return p
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def get_thumbnails_dir() -> Path:
|
|
19
|
+
d = get_data_dir() / 'thumbnails'
|
|
20
|
+
d.mkdir(parents=True, exist_ok=True)
|
|
21
|
+
return d
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def get_thumbs_map_path() -> Path:
|
|
25
|
+
return get_data_dir() / 'thumbs.json'
|
|
26
|
+
|
|
@@ -0,0 +1,139 @@
|
|
|
1
|
+
import os
|
|
2
|
+
import sys
|
|
3
|
+
import argparse
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
from waitress import serve
|
|
6
|
+
from tiklocal.app import create_app
|
|
7
|
+
from tiklocal.thumbs import generate_thumbnails
|
|
8
|
+
from tiklocal.paths import get_data_dir
|
|
9
|
+
|
|
10
|
+
try:
|
|
11
|
+
import yaml
|
|
12
|
+
except ImportError:
|
|
13
|
+
yaml = None
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def load_config():
|
|
17
|
+
"""从配置文件加载配置"""
|
|
18
|
+
config = {}
|
|
19
|
+
|
|
20
|
+
# 尝试读取配置文件
|
|
21
|
+
config_paths = [
|
|
22
|
+
Path.home() / '.config' / 'tiklocal' / 'config.yaml',
|
|
23
|
+
Path.home() / '.tiklocal' / 'config.yaml',
|
|
24
|
+
]
|
|
25
|
+
|
|
26
|
+
for config_path in config_paths:
|
|
27
|
+
if config_path.exists():
|
|
28
|
+
if yaml is None:
|
|
29
|
+
print(f"警告: 找到配置文件 {config_path} 但未安装 PyYAML,跳过配置文件", file=sys.stderr)
|
|
30
|
+
break
|
|
31
|
+
try:
|
|
32
|
+
with open(config_path, 'r', encoding='utf-8') as f:
|
|
33
|
+
config = yaml.safe_load(f) or {}
|
|
34
|
+
break
|
|
35
|
+
except Exception as e:
|
|
36
|
+
print(f"警告: 读取配置文件 {config_path} 失败: {e}", file=sys.stderr)
|
|
37
|
+
|
|
38
|
+
return config
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def main():
|
|
42
|
+
# 读取配置文件
|
|
43
|
+
config = load_config()
|
|
44
|
+
|
|
45
|
+
# 预处理 argv,支持以下形式:
|
|
46
|
+
# 1) tiklocal -> serve
|
|
47
|
+
# 2) tiklocal /path -> serve /path
|
|
48
|
+
# 3) tiklocal --port 9000 -> serve --port 9000
|
|
49
|
+
# 4) tiklocal thumbs /path -> thumbs /path
|
|
50
|
+
# 5) tiklocal /path thumbs -> thumbs /path
|
|
51
|
+
argv = sys.argv[1:]
|
|
52
|
+
if '-h' not in argv and '--help' not in argv:
|
|
53
|
+
if 'thumbs' in argv:
|
|
54
|
+
idx = argv.index('thumbs')
|
|
55
|
+
if idx != 0:
|
|
56
|
+
argv.pop(idx)
|
|
57
|
+
argv.insert(0, 'thumbs')
|
|
58
|
+
elif len(argv) == 0 or argv[0] not in ('serve', 'thumbs'):
|
|
59
|
+
# 默认回退 serve(空参数或第一个不是已知子命令)
|
|
60
|
+
argv.insert(0, 'serve')
|
|
61
|
+
|
|
62
|
+
# 解析命令行参数(支持子命令)
|
|
63
|
+
parser = argparse.ArgumentParser(
|
|
64
|
+
description='TikLocal - 本地媒体服务器',
|
|
65
|
+
formatter_class=argparse.RawDescriptionHelpFormatter,
|
|
66
|
+
epilog='''
|
|
67
|
+
示例:
|
|
68
|
+
tiklocal # 启动服务(默认)
|
|
69
|
+
tiklocal /path/to/media # 指定媒体目录
|
|
70
|
+
tiklocal --port 9000 # 使用指定端口
|
|
71
|
+
tiklocal serve /path --port 9000 # 显式使用 serve 子命令
|
|
72
|
+
tiklocal thumbs /path --overwrite # 批量生成缩略图
|
|
73
|
+
'''
|
|
74
|
+
)
|
|
75
|
+
|
|
76
|
+
subparsers = parser.add_subparsers(dest='command')
|
|
77
|
+
|
|
78
|
+
# serve 子命令
|
|
79
|
+
serve_parser = subparsers.add_parser('serve', help='启动服务器')
|
|
80
|
+
serve_parser.add_argument('media_root', nargs='?', help='媒体文件根目录路径')
|
|
81
|
+
serve_parser.add_argument('--host', default=None, help='服务器监听地址 (默认: 0.0.0.0)')
|
|
82
|
+
serve_parser.add_argument('--port', type=int, default=None, help='服务器端口 (默认: 8000)')
|
|
83
|
+
|
|
84
|
+
# thumbs 子命令
|
|
85
|
+
thumbs_parser = subparsers.add_parser('thumbs', help='批量生成视频缩略图')
|
|
86
|
+
thumbs_parser.add_argument('media_root', nargs='?', help='媒体文件根目录路径(可省略以使用环境变量/配置文件)')
|
|
87
|
+
thumbs_parser.add_argument('--overwrite', action='store_true', help='存在时覆盖重建')
|
|
88
|
+
thumbs_parser.add_argument('--limit', type=int, default=0, help='最多处理多少个(0 表示全部)')
|
|
89
|
+
|
|
90
|
+
args = parser.parse_args(argv)
|
|
91
|
+
|
|
92
|
+
# 判断命令类型(无子命令时视为 serve)
|
|
93
|
+
cmd = args.command or 'serve'
|
|
94
|
+
|
|
95
|
+
if cmd == 'thumbs':
|
|
96
|
+
media_root = args.media_root or os.environ.get('MEDIA_ROOT') or config.get('media_root')
|
|
97
|
+
if not media_root:
|
|
98
|
+
parser.error('必须指定媒体目录:\n - tiklocal thumbs /path/to/media\n - 或设置环境变量: MEDIA_ROOT=/path/to/media')
|
|
99
|
+
media_path = Path(media_root)
|
|
100
|
+
if not media_path.exists() or not media_path.is_dir():
|
|
101
|
+
print(f"错误: 媒体目录不可用: {media_root}", file=sys.stderr)
|
|
102
|
+
sys.exit(1)
|
|
103
|
+
print(f"数据目录: {get_data_dir()}")
|
|
104
|
+
stats = generate_thumbnails(media_path, overwrite=getattr(args, 'overwrite', False), limit=getattr(args, 'limit', 0), show_progress=True)
|
|
105
|
+
# 完成后退出
|
|
106
|
+
return
|
|
107
|
+
|
|
108
|
+
# serve 路径
|
|
109
|
+
media_root = args.media_root or os.environ.get('MEDIA_ROOT') or config.get('media_root')
|
|
110
|
+
host = args.host or os.environ.get('TIKLOCAL_HOST') or config.get('host', '0.0.0.0')
|
|
111
|
+
port = args.port or int(os.environ.get('TIKLOCAL_PORT', 0)) or config.get('port', 8000)
|
|
112
|
+
|
|
113
|
+
# 验证媒体目录
|
|
114
|
+
if not media_root:
|
|
115
|
+
parser.error('必须指定媒体目录:\n - 通过命令行参数: tiklocal /path/to/media\n - 通过环境变量: MEDIA_ROOT=/path/to/media\n - 通过配置文件: ~/.config/tiklocal/config.yaml')
|
|
116
|
+
|
|
117
|
+
media_path = Path(media_root)
|
|
118
|
+
if not media_path.exists():
|
|
119
|
+
print(f"错误: 媒体目录不存在: {media_root}", file=sys.stderr)
|
|
120
|
+
sys.exit(1)
|
|
121
|
+
|
|
122
|
+
if not media_path.is_dir():
|
|
123
|
+
print(f"错误: 路径不是目录: {media_root}", file=sys.stderr)
|
|
124
|
+
sys.exit(1)
|
|
125
|
+
|
|
126
|
+
# 设置环境变量供 Flask 使用
|
|
127
|
+
os.environ['MEDIA_ROOT'] = str(media_path.absolute())
|
|
128
|
+
|
|
129
|
+
# 启动服务器
|
|
130
|
+
print(f"启动 TikLocal 服务器...")
|
|
131
|
+
print(f"媒体目录: {media_path.absolute()}")
|
|
132
|
+
print(f"数据目录: {get_data_dir()}")
|
|
133
|
+
print(f"访问地址: http://{host}:{port}")
|
|
134
|
+
|
|
135
|
+
serve(create_app(), host=host, port=port)
|
|
136
|
+
|
|
137
|
+
|
|
138
|
+
if __name__ == '__main__':
|
|
139
|
+
main()
|